Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion js/build/ai-agent.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/build/translations/ar.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/build/translations/es.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/build/translations/hi.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/build/translations/nl.js

Large diffs are not rendered by default.

154 changes: 77 additions & 77 deletions js/ckeditor5_plugins/aiagent/src/SUPPORTED_MODELS.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions js/ckeditor5_plugins/aiagent/src/aiagentcontext.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export declare class AiAgentContext {
private constructor();
static getInstance(): AiAgentContext;
set uiComponent(component: any);
get uiComponent(): any;
showError(message: string): void;
showLoader(editor: Editor): void;
hideLoader(editor: Editor): void;
Expand Down
3 changes: 3 additions & 0 deletions js/ckeditor5_plugins/aiagent/src/aiagentcontext.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export class AiAgentContext {
set uiComponent(component) {
this._uiComponent = component;
}
get uiComponent() {
return this._uiComponent;
}
showError(message) {
if (this._uiComponent) {
console.log('Showing error message...', message);
Expand Down
48 changes: 42 additions & 6 deletions js/ckeditor5_plugins/aiagent/src/aiagentservice.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,20 @@ export default class AiAgentService {
*/
constructor(editor) {
this.editor = editor;
const config = editor.config.get('aiAgent');
this.promptHelper = new PromptHelper(editor);
this.htmlParser = new HtmlParser(editor);
this.processContentHelper = new ProcessContentHelper(editor);
const config = editor.config.get('aiAgent');
// Wire up blocked URL notifications to the UI component
const filterConfig = {
...config.aiOutputSecurity,
onUrlBlocked: (blockedUrls) => {
const uiComponent = aiAgentContext.uiComponent;
if (uiComponent?.showBlockedUrlsWarning) {
uiComponent.showBlockedUrlsWarning(blockedUrls);
}
}
};
this.processContentHelper = new ProcessContentHelper(editor, filterConfig);
this.aiModel = config.model;
this.apiKey = config.apiKey;
this.aiEngine = config.engine;
Expand Down Expand Up @@ -109,10 +119,37 @@ export default class AiAgentService {
if (command) {
const selection = model.document.selection;
const selectedContentFragment = model.getSelectedContent(selection);
const viewFragment = editor.data.toView(selectedContentFragment);
const html = editor.data.processor.toData(viewFragment);
const firstPos = selection.getFirstPosition();
const lastPos = selection.getLastPosition();
const blocks = Array.from(selection.getSelectedBlocks());
const block = blocks[0];
let pathsEqual = false;
if (block) {
const range = model.createRangeIn(block);
const startLine = range.start.path;
const endLine = range.end.path;
const firstPosPath = firstPos?.path;
const lastPosPath = lastPos?.path;
pathsEqual = Boolean(firstPosPath && lastPosPath &&
firstPosPath.length === startLine.length &&
lastPosPath.length === endLine.length &&
firstPosPath.every((val, i) => val === startLine[i]) &&
lastPosPath.every((val, i) => val === endLine[i]));
}
if (pathsEqual && block) {
const fragHtml = editor.model.change(writer => {
const rangeOnBlock = writer.createRangeOn(block);
const frag = model.getSelectedContent(model.createSelection(rangeOnBlock));
const viewFrag = editor.data.toView(frag);
return editor.data.processor.toData(viewFrag);
});
selectedContent = fragHtml;
}
else {
const viewFragment = editor.data.toView(selectedContentFragment);
selectedContent = editor.data.processor.toData(viewFragment);
}
content = command;
selectedContent = html;
}
if (this.moderationEnable) {
const moderateContentData = await moderateContent({
Expand Down Expand Up @@ -176,7 +213,6 @@ export default class AiAgentService {
content: contentMatch[1]
};
}
const x = false;
if (AI_ENGINE.includes(this.aiEngine)) {
const config = {
apiKey: this.apiKey
Expand Down
23 changes: 0 additions & 23 deletions js/ckeditor5_plugins/aiagent/src/aiagenttonecommand.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,6 @@ export default class AiAgentToneCommand extends Command {
execute({ value }: {
value: string;
}): Promise<void>;
/**
* Saves the selected tone to localStorage for future use.
*
* This method stores the specified tone under a unique key in localStorage,
* allowing the application to remember the user's tone preference across sessions.
* It also logs the saved value for debugging purposes if debug mode is enabled.
*
* @param toneKey - The toneKey string to be saved in localStorage.
* @returns {void} This function does not return a value.
*
* @throws {Error} If localStorage is not available, a warning is logged to the console.
*/
private saveToneSelection;
/**
* Loads the selected tone from localStorage.
*
* This method retrieves the tone string stored under a unique key in localStorage,
* allowing the application to remember the user's tone preference across sessions.
* If no tone is found, it returns null.
*
* @returns {string | null} The stored tone string if found, or null if no tone is stored.
*
* @throws {Error} If localStorage is not available, a warning is logged to the console.
*/
private loadToneSelection;
}
63 changes: 7 additions & 56 deletions js/ckeditor5_plugins/aiagent/src/aiagenttonecommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,66 +42,17 @@ export default class AiAgentToneCommand extends Command {
this.saveToneSelection(selectedTone.key);
}
}
/**
* Saves the selected tone to localStorage for future use.
*
* This method stores the specified tone under a unique key in localStorage,
* allowing the application to remember the user's tone preference across sessions.
* It also logs the saved value for debugging purposes if debug mode is enabled.
*
* @param toneKey - The toneKey string to be saved in localStorage.
* @returns {void} This function does not return a value.
*
* @throws {Error} If localStorage is not available, a warning is logged to the console.
*/
saveToneSelection(toneKey) {
try {
const key = `${STORAGE_PREFIX}:${this.STORAGE_KEY}`;
// Compare with models endpoint cache key format
const modelsKey = `${STORAGE_PREFIX}:openai_models`;
const hasModelsCache = localStorage.getItem(modelsKey) !== null;
localStorage.setItem(key, toneKey);
if (this.debugMode) {
const savedValue = localStorage.getItem(key);
console.log('[DEBUG] Tone localStorage:', {
key,
toneKey,
savedValue,
modelsKey,
hasModelsCache
});
}
}
catch (error) {
// Fail silently if localStorage is not available
console.warn('Could not save tone to localStorage', error);
}
const key = `${STORAGE_PREFIX}:${this.STORAGE_KEY}`;
localStorage.setItem(key, toneKey);
}
/**
* Loads the selected tone from localStorage.
*
* This method retrieves the tone string stored under a unique key in localStorage,
* allowing the application to remember the user's tone preference across sessions.
* If no tone is found, it returns null.
*
* @returns {string | null} The stored tone string if found, or null if no tone is stored.
*
* @throws {Error} If localStorage is not available, a warning is logged to the console.
*/
loadToneSelection() {
try {
const key = `${STORAGE_PREFIX}:${this.STORAGE_KEY}`;
const storedToneKey = localStorage.getItem(key);
if (!storedToneKey) {
return null;
}
const matchingTone = this.availableTones.find(item => item.key === storedToneKey);
return matchingTone ? matchingTone.tone : null;
}
catch (error) {
// Fail silently if localStorage is not available
console.warn('Could not load tone from localStorage', error);
const key = `${STORAGE_PREFIX}:${this.STORAGE_KEY}`;
const storedToneKey = localStorage.getItem(key);
if (!storedToneKey) {
return null;
}
const matchingTone = this.availableTones.find(item => item.key === storedToneKey);
return matchingTone ? matchingTone.tone : null;
}
}
20 changes: 19 additions & 1 deletion js/ckeditor5_plugins/aiagent/src/aiagentui.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,26 @@ export default class AiAgentUI extends Plugin {
* Displays an error tooltip with the specified message.
*
* @param message - The error message to display in the tooltip.
* @param options - Optional configuration for the tooltip.
*/
showGptErrorToolTip(message: string): void;
showGptErrorToolTip(message: string, options?: {
type?: 'error' | 'warning';
html?: boolean;
duration?: number;
}): void;
/**
* Displays a warning notification for blocked URLs.
*
* @param blockedUrls - Object containing arrays of blocked image and link URLs.
*/
showBlockedUrlsWarning(blockedUrls: {
images: string[];
links: string[];
}): void;
/**
* Escapes HTML special characters to prevent XSS.
*/
private escapeHtml;
/**
* Hides the error tooltip element from the document.
*/
Expand Down
76 changes: 70 additions & 6 deletions js/ckeditor5_plugins/aiagent/src/aiagentui.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { env } from 'ckeditor5/src/utils.js';
import { addAiAgentButton } from './util/ai-agent-button.js';
import { addAiAgentToneButton } from './util/ai-agent-tone-button.js';
import { registerInlineSlashSchema, registerAiTagSchema, registerAiAnimateStatusSchema } from './util/ai-agent-ui-schema.js';
/** Maximum number of blocked URLs to display in the warning notification */
const MAX_BLOCKED_URLS_DISPLAYED = 5;
/** Maximum URL length before truncation in the warning notification */
const MAX_URL_DISPLAY_LENGTH = 50;
/** Duration in ms to show blocked URL warning (longer than errors for user to read URL list) */
const BLOCKED_URL_WARNING_DURATION = 15000;
export default class AiAgentUI extends Plugin {
PLACEHOLDER_TEXT_ID = 'slash-placeholder';
GPT_RESPONSE_LOADER_ID = 'gpt-response-loader';
Expand Down Expand Up @@ -62,14 +68,17 @@ export default class AiAgentUI extends Plugin {
label: t('AI Agent'),
keystrokes: [
{
label: t('Slash Command: Open the AI Command Menu in an Empty Field'),
label: t('Type / to Start Inline AI Prompt'),
keystroke: '/'
},
{
// eslint-disable-next-line max-len
label: t('Force Insert Slash Command: Add a Slash Command Within Existing Text'),
label: t('Insert / to Start AI Prompt Within Existing Text'),
keystroke: env.isMac ? 'Cmd + /' : 'Ctrl + /'
},
{
label: t('Submit Command from "Ask AI to Edit" Field in Dropdown'),
keystroke: env.isMac ? 'Cmd + Enter' : 'Ctrl + Enter'
},
{
label: t('Cancel AI Generation'),
keystroke: env.isMac ? 'Cmd + Backspace' : 'Ctrl + Backspace'
Expand Down Expand Up @@ -301,20 +310,75 @@ export default class AiAgentUI extends Plugin {
* Displays an error tooltip with the specified message.
*
* @param message - The error message to display in the tooltip.
* @param options - Optional configuration for the tooltip.
*/
showGptErrorToolTip(message) {
showGptErrorToolTip(message, options) {
console.log('Showing error message...', message);
const editor = this.editor;
const view = editor?.editing?.view?.domRoots?.get('main');
const tooltipElement = document.getElementById(this.GPT_RESPONSE_ERROR_ID);
const editorRect = view?.getBoundingClientRect();
if (tooltipElement && editorRect) {
tooltipElement.classList.remove('response-error--warning');
if (options?.type === 'warning') {
tooltipElement.classList.add('response-error--warning');
}
tooltipElement.classList.add('show-response-error');
tooltipElement.textContent = message;
if (options?.html) {
tooltipElement.innerHTML = message;
}
else {
tooltipElement.textContent = message;
}
const duration = options?.duration ?? this.showErrorDuration;
setTimeout(() => {
this.hideGptErrorToolTip();
}, this.showErrorDuration);
}, duration);
}
}
/**
* Displays a warning notification for blocked URLs.
*
* @param blockedUrls - Object containing arrays of blocked image and link URLs.
*/
showBlockedUrlsWarning(blockedUrls) {
const totalBlocked = blockedUrls.images.length + blockedUrls.links.length;
if (totalBlocked === 0)
return;
const t = this.editor.t;
const parts = [];
if (blockedUrls.images.length > 0) {
const imageWord = blockedUrls.images.length === 1 ? t('image') : t('images');
parts.push(`${blockedUrls.images.length} ${imageWord}`);
}
if (blockedUrls.links.length > 0) {
const linkWord = blockedUrls.links.length === 1 ? t('link') : t('links');
parts.push(`${blockedUrls.links.length} ${linkWord}`);
}
const allUrls = [...blockedUrls.images, ...blockedUrls.links];
const displayUrls = allUrls.slice(0, MAX_BLOCKED_URLS_DISPLAYED);
const remainingCount = allUrls.length - displayUrls.length;
const urlListItems = displayUrls.map(url => {
const truncated = url.length > MAX_URL_DISPLAY_LENGTH
? `${url.substring(0, MAX_URL_DISPLAY_LENGTH)}...`
: url;
return `<li>${this.escapeHtml(truncated)}</li>`;
});
if (remainingCount > 0) {
urlListItems.push(`<li>...${t('and %0 more', [remainingCount])}</li>`);
}
const message = `<strong>${t('External URLs filtered')}</strong><br>` +
`${parts.join(` ${t('and')} `)} ${t('blocked for security.')}<br>` +
`<ul class="blocked-urls-list">${urlListItems.join('')}</ul>`;
this.showGptErrorToolTip(message, { type: 'warning', html: true, duration: BLOCKED_URL_WARNING_DURATION });
}
/**
* Escapes HTML special characters to prevent XSS.
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Hides the error tooltip element from the document.
Expand Down
2 changes: 2 additions & 0 deletions js/ckeditor5_plugins/aiagent/src/const.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export declare const MODERATION_URL = "https://api.openai.com/v1/moderations";
export declare const ALL_MODERATION_FLAGS: readonly ["harassment", "harassment/threatening", "hate", "hate/threatening", "self-harm", "self-harm/instructions", "self-harm/intent", "sexual", "sexual/minors", "violence", "violence/graphic"];
export declare const SHOW_ERROR_DURATION = 5000;
export declare const STORAGE_PREFIX = "ck5-ai-agent";
/** Domain for the placeholder image service used for AI-generated images */
export declare const PLACEHOLDER_SERVICE_DOMAIN = "promptahuman.com";
2 changes: 2 additions & 0 deletions js/ckeditor5_plugins/aiagent/src/const.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ export const ALL_MODERATION_FLAGS = [
];
export const SHOW_ERROR_DURATION = 5000;
export const STORAGE_PREFIX = 'ck5-ai-agent';
/** Domain for the placeholder image service used for AI-generated images */
export const PLACEHOLDER_SERVICE_DOMAIN = 'promptahuman.com';
8 changes: 0 additions & 8 deletions js/ckeditor5_plugins/aiagent/src/index.ts

This file was deleted.

12 changes: 12 additions & 0 deletions js/ckeditor5_plugins/aiagent/src/type-identifiers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export interface AiAgentConfig {
moderationKey?: string;
moderationEnable?: boolean;
moderationDisableFlags?: Array<ModerationFlagsTypes>;
aiOutputSecurity?: {
/**
* Allowed domains for images. Supports wildcards (*.example.com).
* Default: ['promptahuman.com']. Use [] to block all, ['*'] to allow all.
*/
allowedImageDomains?: string[];
/**
* Allowed domains for links. Supports wildcards (*.example.com).
* Default: [] (blocks all). Use ['*'] to allow all external links.
*/
allowedLinkDomains?: string[];
};
commandsDropdown?: Array<{
title: string;
items: Array<{
Expand Down
Loading