Skip to content

feat(ckeditor): GPL AI assistant for text notes - #10730

Draft
eliandoran wants to merge 6 commits into
mainfrom
feature/ai_assistant
Draft

feat(ckeditor): GPL AI assistant for text notes#10730
eliandoran wants to merge 6 commits into
mainfrom
feature/ai_assistant

Conversation

@eliandoran

Copy link
Copy Markdown
Contributor

Summary

A GPL-licensed AI assistant for the rich-text editor, replicating the core of CKEditor's premium AIAssistant with zero premium dependencies and zero new server code:

  • Balloon on the selection (toolbar ✨ button, /ai slash entry) with a free-form prompt and a grouped Quick actions dropdown (fix typos, improve writing, shorter/longer, simplify, summarize, continue, tone, translate).
  • Streaming preview — the response streams into a detached ck-content preview; the document is never touched mid-stream. Committing via Replace / Insert below is one model.insertContent inside one model.change() → one undo step, schema-filtered through the data pipeline.
  • Result/Changes diff review — inline ins/del diff of the response against the content it replaces, rendered with htmldiff-js (the same mechanism as the revisions dialog). Computed only from complete streams; follow-up queries diff against the response they refine.
  • Usage line — model · tokens · ~cost in the review row, fed by the SSE usage chunk the LLM chat already receives.
  • Chaining — follow-up prompts and quick actions operate on the previous response; Try again reruns against what the last run saw; Stop keeps the partial result usable.

Architecture

The plugin (packages/ckeditor5/src/plugins/ai_assistant/) is fully provider-agnostic: the client injects stream, diff and quickActions through config.aiAssistant (same pattern as snippets.definitions). The transport (apps/client/.../ai_assistant_stream.ts) reuses the existing /api/llm-chat/stream endpoint and the user's configured LLM provider; without a provider the command, button and slash entry disable themselves. The target selection is pinned with an affectsData: false marker (highlight-tinted) so it survives focus moving into the balloon. The stream contract ({query, context, cumulative onData, abort}) mirrors the premium AITextAdapter shape.

Testing

  • 16 unit tests (browser-mode): HTML sanitizer, fence stripping, form lifecycle, diff toggle, usage line, quick-action gating via real DOM clicks
  • pnpm typecheck clean; no new lint findings

Known limitations / follow-ups

  • Provider/model fixed to the default; a per-run picker would fit in the balloon
  • Provider set is read at editor build time (same live-reload gap snippets had; same fix applies)
  • No balloon-orchestration tests yet (marker cleanup, abort paths)
  • Surfacing top quick actions as direct / slash entries

🤖 Generated with Claude Code

eliandoran and others added 6 commits July 28, 2026 16:34
…lace/insert

A balloon on the selection asks the configured LLM provider to rewrite the
selection or generate new content. The response streams into a detached
ck-content preview (the document is never touched mid-stream); committing via
Replace or Insert below is a single model.insertContent in one model.change(),
so one undo step. Follow-up queries chain on the previous response, Try again
re-runs against what the last run saw, and Stop keeps the partial result
reviewable.

The plugin is provider-agnostic: the client injects the transport through
config.aiAssistant.stream (cumulative-HTML callback, the same shape as the
premium AITextAdapter contract), implemented on top of the existing
/api/llm-chat/stream SSE endpoint. Without a configured provider the command,
toolbar button and /ai slash entry all disable themselves.

The target selection is pinned with an affectsData:false marker (tinted via a
highlight downcast) so it survives focus moving into the balloon; the preview
sanitizes streamed HTML and strips markdown fences, both covered by unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The list view's `_rebuild` and the menu's `_populateMenuList` called
`items.clear()` before recreating rows, but a ViewCollection's clear() only
detaches its views — it does not destroy them. Rebuilding on every snippet
change therefore leaked the old row/menu buttons and their `execute` listeners.
Destroy the previous views (which cascades to their icon/text subviews) before
discarding them, and cover it with a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…view

When a run finishes, the review phase now offers an inline HTML diff of the
response against the content it applies to, rendered with ins/del markup and
toggled via Result/Changes buttons in the balloon heading. The diff defaults on
when available, is computed only from a complete stream (a partial response
would diff as mass deletion), and for follow-up queries compares against the
response being refined rather than the original selection.

The renderer is host-injected through config.aiAssistant.diff, keeping the
plugin dependency-free; the client wires htmldiff-js, the same mechanism the
revisions dialog uses. A diff-renderer failure only costs the Changes view,
never the response. Generate-from-scratch runs have no diff (nothing to diff
against), so the toggle stays hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The preview now wraps instead of scrolling horizontally: the element opts out
of CKEditor's UI reset via ck-reset_all-excluded (inside the balloon the reset
forces white-space: nowrap, zeroed margins and the UI font onto every
descendant), wraps text and code blocks (Trilium's theme pins `pre code` to
`white-space: pre` when the editor's word-wrap class is absent, which this
detached preview always lacks), and constrains images. Wide tables keep their
scrollbar — clipping table data would be worse.

The review actions row also gains a usage line (model · tokens · ~cost),
right-aligned and muted. The stream contract now resolves with an optional
AiCompletionUsage; the client transport fills it from the SSE usage chunk the
chat already receives. Only provider-reported fields are shown, and sub-cent
costs get four decimals where the chat's two would render ~$0.00.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A grouped "Quick actions" dropdown in the balloon's prompt row runs predefined
instructions with one click — Edit or review (fix typos, improve writing, make
shorter/longer, simplify), Generate (summarize, continue), Change tone, and
Translate (the six UI languages). Modelled on the premium assistant's default
command palette.

The action set is host-injected through config.aiAssistant.quickActions with
pre-translated labels; the plugin ships no prompts of its own and hides the
dropdown when none are configured. Content-requiring actions are disabled while
there is nothing to work on (collapsed selection) and unlock once a response
exists, so quick actions chain on each other like typed queries. Everything
downstream — streaming preview, Changes diff, usage line, Try again — applies
to a quick action exactly as to a typed prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eliandoran
eliandoran changed the base branch from refactor/template_redesign to main July 28, 2026 14:52
@github-actions

Copy link
Copy Markdown
Contributor

🖥️ App preview is ready!

🔗 Preview URL: https://pr-10730.trilium-app.pages.dev
📖 Production URL: https://app.triliumnotes.org

✅ All checks passed

This preview will be updated automatically with new commits.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 19.18kB (0.02%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
standalone-esm 53.26MB 19.18kB (0.04%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: standalone-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/abstract_provider-BnAl9LZP.js (New) 2.05MB 2.05MB 100.0% 🚀
src/src4.js 12.61kB 585.37kB 2.2%
src/src.css 2.51kB 256.28kB 0.99%
assets/src-D9iNF24k.js (New) 194.57kB 194.57kB 100.0% 🚀
assets/crypto_provider-B7WouN0y.js (New) 96.76kB 96.76kB 100.0% 🚀
assets/in_app_help_provider-BZDn5aK7.js (New) 79.29kB 79.29kB 100.0% 🚀
assets/zip-D6xkbIBo.js (New) 56.14kB 56.14kB 100.0% 🚀
src/ChatReadOnlyNotice.js 27 bytes 31.1kB 0.09%
src/EditableText.js 3.9kB 27.53kB 16.49% ⚠️
src/AddProviderModal.js -1.99kB 21.43kB -8.5%
src/revisions.js -8.89kB 13.32kB -40.03%
src/htmldiff.min.js (New) 8.99kB 8.99kB 100.0% 🚀
assets/browser_routes-4BFZ-TIe.js (New) 8.21kB 8.21kB 100.0% 🚀
assets/becca_loader-_WZMFh-q.js (New) 5.71kB 5.71kB 100.0% 🚀
assets/local-server-worker-DNBG_XC1.js (New) 4.65kB 4.65kB 100.0% 🚀
assets/html-C7QbchVM.js (New) 2.93kB 2.93kB 100.0% 🚀
assets/ru-Dt9TCIeb.js (New) 2.39kB 2.39kB 100.0% 🚀
assets/backup_provider-DkT5ImW-.js (New) 2.32kB 2.32kB 100.0% 🚀
assets/uk-B91dcYkw.js (New) 2.31kB 2.31kB 100.0% 🚀
src/llm_chat.js (New) 2.04kB 2.04kB 100.0% 🚀
assets/log_provider-Dw7RFI4l.js (New) 1.96kB 1.96kB 100.0% 🚀
assets/0216__move_content_into_blobs-B8O4qiHO.js (New) 1.9kB 1.9kB 100.0% 🚀
assets/zip_export_provider_factory-C2-gc_bL.js (New) 1.83kB 1.83kB 100.0% 🚀
assets/ar-CJYmV9oA.js (New) 1.81kB 1.81kB 100.0% 🚀
assets/cs-sNvC-Bxz.js (New) 1.78kB 1.78kB 100.0% 🚀
assets/pl-D4aVpXuv.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/hi-CCLC0Y6A.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/zh-cn-BsF9SKOF.js (New) 1.54kB 1.54kB 100.0% 🚀
assets/de-CyDSkVcD.js (New) 1.52kB 1.52kB 100.0% 🚀
assets/zh-tw-DfniaIG9.js (New) 1.51kB 1.51kB 100.0% 🚀
assets/ja-BirtaFGP.js (New) 1.35kB 1.35kB 100.0% 🚀
assets/pt-DF6BCZ24.js (New) 1.3kB 1.3kB 100.0% 🚀
assets/ga-Dr1-meNo.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/en-gb-BbSSVn6Q.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/pt-br-DDAUcyQ5.js (New) 1.28kB 1.28kB 100.0% 🚀
assets/fr-CGAG6LfB.js (New) 1.27kB 1.27kB 100.0% 🚀
assets/es-Dp4AETnx.js (New) 1.25kB 1.25kB 100.0% 🚀
assets/it-CcLUIAwl.js (New) 1.23kB 1.23kB 100.0% 🚀
assets/ro-BhEaURYc.js (New) 1.22kB 1.22kB 100.0% 🚀
assets/id-D6Nr-85Z.js (New) 1.22kB 1.22kB 100.0% 🚀
assets/0233__migrate_geo_map_to_collection-BprTgbEa.js (New) 777 bytes 777 bytes 100.0% 🚀
assets/0220__migrate_images_to_attachments-Cm8dFHHf.js (New) 672 bytes 672 bytes 100.0% 🚀
assets/0239__disable_totp_when_mfa_was_turned_off-CV-K_eGv.js (New) 623 bytes 623 bytes 100.0% 🚀
assets/0234__migrate_ai_chat_to_code-DdUOjagk.js (New) 443 bytes 443 bytes 100.0% 🚀
assets/markdown-BPBhO5n2.js (New) 323 bytes 323 bytes 100.0% 🚀
src/src5.js 5 bytes 127 bytes 4.1%
assets/abstract_provider-Cpt4BEXk.js (Deleted) -2.05MB 0 bytes -100.0% 🗑️
assets/src-DM3D3SMb.js (Deleted) -194.57kB 0 bytes -100.0% 🗑️
assets/crypto_provider-CoWo6sC5.js (Deleted) -96.76kB 0 bytes -100.0% 🗑️
assets/in_app_help_provider-4BnCXKaU.js (Deleted) -79.29kB 0 bytes -100.0% 🗑️
assets/zip-Bw0Z9fwD.js (Deleted) -56.14kB 0 bytes -100.0% 🗑️
assets/browser_routes-xAid5bQI.js (Deleted) -8.21kB 0 bytes -100.0% 🗑️
assets/becca_loader-CPPv9UTw.js (Deleted) -5.71kB 0 bytes -100.0% 🗑️
assets/local-server-worker-Ds1fld5O.js (Deleted) -4.65kB 0 bytes -100.0% 🗑️
assets/html-MehlYlep.js (Deleted) -2.93kB 0 bytes -100.0% 🗑️
assets/ru-zDTaMJ0t.js (Deleted) -2.39kB 0 bytes -100.0% 🗑️
assets/backup_provider-CZDWZMre.js (Deleted) -2.32kB 0 bytes -100.0% 🗑️
assets/uk-ngDRzCDe.js (Deleted) -2.31kB 0 bytes -100.0% 🗑️
assets/log_provider-C4XlcEQh.js (Deleted) -1.96kB 0 bytes -100.0% 🗑️
assets/0216__move_content_into_blobs-6W2_jFBP.js (Deleted) -1.9kB 0 bytes -100.0% 🗑️
assets/zip_export_provider_factory-C2HdmamJ.js (Deleted) -1.83kB 0 bytes -100.0% 🗑️
assets/ar-YC00HZvA.js (Deleted) -1.81kB 0 bytes -100.0% 🗑️
assets/cs-BhNBdMpj.js (Deleted) -1.78kB 0 bytes -100.0% 🗑️
assets/pl-GgvZxZ8N.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/hi-DzvY_-k4.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/zh-cn-CCSxXSsd.js (Deleted) -1.54kB 0 bytes -100.0% 🗑️
assets/de-D8rCjj6U.js (Deleted) -1.52kB 0 bytes -100.0% 🗑️
assets/zh-tw-BY7K_TAN.js (Deleted) -1.51kB 0 bytes -100.0% 🗑️
assets/ja-BFOCbGbI.js (Deleted) -1.35kB 0 bytes -100.0% 🗑️
assets/pt-4b4NfHha.js (Deleted) -1.3kB 0 bytes -100.0% 🗑️
assets/ga-VmTv3smV.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/en-gb-CG4_1xPI.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/pt-br-C5-bDet4.js (Deleted) -1.28kB 0 bytes -100.0% 🗑️
assets/fr-C_s8LPvm.js (Deleted) -1.27kB 0 bytes -100.0% 🗑️
assets/es--EDeeSWv.js (Deleted) -1.25kB 0 bytes -100.0% 🗑️
assets/it-wm72XVoq.js (Deleted) -1.23kB 0 bytes -100.0% 🗑️
assets/ro-B1QMX94p.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/id--0SuntDW.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/0233__migrate_geo_map_to_collection-BGitlmUq.js (Deleted) -777 bytes 0 bytes -100.0% 🗑️
assets/0220__migrate_images_to_attachments-BzfyAu6v.js (Deleted) -672 bytes 0 bytes -100.0% 🗑️
assets/0239__disable_totp_when_mfa_was_turned_off-EST_gL40.js (Deleted) -623 bytes 0 bytes -100.0% 🗑️
assets/0234__migrate_ai_chat_to_code-CqTxT0Zj.js (Deleted) -443 bytes 0 bytes -100.0% 🗑️
assets/markdown-agkZgUtg.js (Deleted) -323 bytes 0 bytes -100.0% 🗑️

Comment on lines +16 to +35
export function sanitizeAiHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");

for (const element of doc.querySelectorAll(DISALLOWED_ELEMENTS.join(","))) {
element.remove();
}

for (const element of doc.body.querySelectorAll("*")) {
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name.toLowerCase();
const isEventHandler = name.startsWith("on");
const isScriptUrl = (name === "href" || name === "src")
&& attribute.value.trim().toLowerCase().startsWith("javascript:");
if (isEventHandler || isScriptUrl) {
element.removeAttribute(attribute.name);
}
}
}

return doc.body.innerHTML;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Incomplete preview HTML sanitizer

When streamed model or diff HTML is shown in the balloon, sanitizeAiHtml only strips a fixed tag list, on* attributes, and javascript: on href/src, then AiPreviewView.setContent assigns that string to innerHTML. Active content outside that strip list (for example xlink:href, data: URLs, base, SVG animate/foreignObject) survives into the editor origin. The client already uses a broader DOMPurify path in sanitize_content.ts for the same class of untrusted HTML.

How this was verified: Traced stream/diff HTML through sanitizeAiHtml into element.innerHTML and compared the strip rules to the existing DOMPurify forbid list.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment on lines +17 to +169
export default function buildAiAssistantStream(): AiStreamFunction | undefined {
if (!readProviderConfigs().length) {
return undefined;
}

return async (request, onData, signal): Promise<AiCompletionUsage> => {
const messages: LlmMessage[] = [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: request.context
? `Content:\n${request.context}\n\nTask: ${request.query}`
: request.query
}
];

const config = pickDefaultModel();
let cumulative = "";
const usage = await new Promise<LlmUsage | null>((resolve, reject) => {
let reported: LlmUsage | null = null;
streamChatCompletion(messages, config, {
onChunk: (text) => {
cumulative += text;
onData(cumulative);
},
onUsage: (chunk) => {
reported = chunk;
},
onError: (error) => reject(new Error(error)),
onDone: () => resolve(reported)
}, signal).then(
// A stream that ends without a "done" event (connection dropped) still settles.
() => resolve(reported),
reject
);
});

return {
// The server reports the model's display name; fall back to the id we asked for.
model: usage?.model ?? config.model,
totalTokens: usage?.totalTokens,
cost: usage?.cost
};
};
}

/**
* The predefined instructions for the balloon's "Quick actions" dropdown, modelled on the premium
* AI assistant's default command set. Labels are translated here (the plugin renders them as
* given); prompts stay English — they are instructions to the model, not UI.
*/
export function buildAiAssistantQuickActions(): AiQuickActionGroup[] {
return [
{
id: "edit",
label: t("ai_assistant.group_edit"),
actions: [
action("fixTypos", t("ai_assistant.action_fix_typos"),
"Fix all spelling, grammar and punctuation mistakes. Do not change the meaning, tone or formatting."),
action("improveWriting", t("ai_assistant.action_improve_writing"),
"Improve the writing: fix mistakes, tighten the phrasing and apply good writing practices without changing the meaning."),
action("makeShorter", t("ai_assistant.action_make_shorter"),
"Shorten this content by removing repetition and non-essential details, without losing key information."),
action("makeLonger", t("ai_assistant.action_make_longer"),
"Expand this content with more detail and clearer explanations, keeping the original meaning."),
action("simplify", t("ai_assistant.action_simplify"),
"Rewrite this content in simpler language so that it is easier to understand.")
]
},
{
id: "generate",
label: t("ai_assistant.group_generate"),
actions: [
action("summarize", t("ai_assistant.action_summarize"),
"Summarize this content into one short paragraph containing only the key ideas and conclusions."),
action("continue", t("ai_assistant.action_continue"),
"Continue writing from the end of the provided content, staying on topic and matching its style. Keep the continuation brief.")
]
},
{
id: "tone",
label: t("ai_assistant.group_tone"),
actions: [
action("professional", t("ai_assistant.tone_professional"),
"Rewrite this content in a polished, formal, professional tone without changing the meaning."),
action("casual", t("ai_assistant.tone_casual"),
"Rewrite this content in a casual, conversational tone without changing the meaning."),
action("direct", t("ai_assistant.tone_direct"),
"Rewrite this content in a direct tone, keeping only the essential information."),
action("friendly", t("ai_assistant.tone_friendly"),
"Rewrite this content in a warm, friendly tone without changing the meaning.")
]
},
{
id: "translate",
label: t("ai_assistant.group_translate"),
actions: [
action("translateEnglish", t("ai_assistant.lang_english"), "Translate the content to English."),
action("translateGerman", t("ai_assistant.lang_german"), "Translate the content to German."),
action("translateSpanish", t("ai_assistant.lang_spanish"), "Translate the content to Spanish."),
action("translateFrench", t("ai_assistant.lang_french"), "Translate the content to French."),
action("translateRomanian", t("ai_assistant.lang_romanian"), "Translate the content to Romanian."),
action("translateChinese", t("ai_assistant.lang_chinese"), "Translate the content to Simplified Chinese.")
]
}
];
}

/** Shorthand for a quick-action entry; all defaults require content to work on. */
function action(id: string, label: string, prompt: string): AiQuickAction {
return { id, label, prompt };
}

/**
* The assistant works HTML-in/HTML-out: the context is the selection's HTML and the response is
* committed through the editor's data pipeline, so anything but clean HTML (markdown, fences,
* commentary) would end up as literal text in the note.
*/
const SYSTEM_PROMPT = `You are a writing assistant embedded in a rich text editor of a note-taking application.
The user gives you a task, usually together with the HTML of the content it applies to.

Rules:
- Respond ONLY with HTML. No markdown, no code fences, no explanations, no preamble.
- Use simple HTML: <p>, <strong>, <em>, <ul>, <ol>, <li>, <h2>-<h5>, <table>, <blockquote>, <code>, <a>.
- When rewriting content, preserve its structure and formatting unless the task says otherwise.
- Respond in the same language as the content, unless the task says otherwise.`;

/** The subset of a stored `llmProviders` entry this module reads. */
interface StoredProviderConfig {
id: string;
provider: string;
selectedModels?: LlmModelInfo[];
}

function readProviderConfigs(): StoredProviderConfig[] {
return (options.getJson("llmProviders") as StoredProviderConfig[] | null) ?? [];
}

/**
* The provider/model the assistant uses: the first configured provider's default model (or its
* first model). The same resolution the LLM chat starts out with — a per-run model picker in the
* balloon can come later.
*/
function pickDefaultModel(): LlmChatConfig {
for (const config of readProviderConfigs()) {
const model = config.selectedModels?.find((m) => m.isDefault) ?? config.selectedModels?.[0];
if (model) {
return { model: model.id, provider: config.provider, providerId: config.id };
}
}
// No selected models anywhere: let the server resolve the provider's own default.
return {};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wrong default LLM provider resolution

When any llmProviders entry exists, the assistant enables even if every entry lacks selectedModels. pickDefaultModel then returns {}, and the stream request reaches the server without provider/providerId/model, so the route falls back to getProviderByType("anthropic"). Runs fail with a missing Anthropic provider when only another provider is configured, or they use the wrong provider/model when Anthropic is present but not the intended default.

Knowledge Base Used: LLM Chat and MCP Integration

Fix in Claude Code

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a GPL CKEditor AI assistant balloon for text notes (stream preview, replace/insert, diff review, quick actions) wired to the existing LLM chat stream, plus a small snippets UI leak fix.

  • CKEditor plugin: balloon UI, target marker, throttled streaming preview, Result/Changes diff, replace/insert below as one undo step
  • Client transport: reuses /api/llm-chat/stream with quick-action prompts and usage display; disables without configured providers
  • Snippets: destroy previous list/menu items on rebuild to avoid view/listener leaks

Confidence Score: 3/5

Not safe to merge until the preview HTML sanitization gap and default LLM provider/model resolution are fixed.

Streamed assistant HTML is rendered with innerHTML behind a narrow custom sanitizer, and empty selectedModels can send the stream with no provider so the server hard-defaults to anthropic, breaking or misrouting runs for otherwise valid setups.

Files Needing Attention: packages/ckeditor5/src/plugins/ai_assistant/ai_html.ts, apps/client/src/widgets/type_widgets/text/ai_assistant_stream.ts

Security Review

Preview assigns model/diff HTML via innerHTML after a custom sanitizer that is weaker than the client’s DOMPurify note-content sanitizer; treat streamed HTML as untrusted until that gap is closed.

Important Files Changed

Filename Overview
packages/ckeditor5/src/plugins/ai_assistant/ai_assistant_ui.ts Orchestrates balloon, streaming, diff, and single-step commit; solid design but depends on host sanitizer and marker lifecycle.
packages/ckeditor5/src/plugins/ai_assistant/ai_html.ts Hand-rolled HTML sanitizer for preview is incomplete versus existing DOMPurify-based client sanitization.
apps/client/src/widgets/type_widgets/text/ai_assistant_stream.ts Transport and quick actions; enablement/default model resolution can miss or mis-route non-Anthropic providers without selectedModels.
packages/ckeditor5/src/plugins/ai_assistant/ai_assistant_form.ts Presentational form/phases with tests; preview uses innerHTML by design after caller sanitize.
packages/ckeditor5/src/plugins/snippets/snippetlistview.ts Destroys previous list rows on rebuild to fix view/listener leaks.

Sequence Diagram

sequenceDiagram
  participant User
  participant Balloon as AiAssistantUI
  participant Stream as buildAiAssistantStream
  participant API as POST /api/llm-chat/stream
  participant Preview as AiPreviewView

  User->>Balloon: Open / prompt / quick action
  Balloon->>Balloon: Pin AI_TARGET_MARKER, capture context HTML
  Balloon->>Stream: stream(query, context, signal)
  Stream->>API: SSE messages + LlmChatConfig
  loop chunks
    API-->>Stream: text delta
    Stream-->>Balloon: cumulative HTML
    Balloon->>Preview: sanitize + throttled innerHTML
  end
  API-->>Stream: usage / done
  User->>Balloon: Replace or Insert below
  Balloon->>Balloon: data pipeline insert at marker (one undo step)
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(ckeditor): add quick actions to the..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant