[pull] main from Milkdown:main#205
Merged
Merged
Conversation
* feat(crepe): add OpenAI and Anthropic AI providers
Ship two built-in `AIProvider` factories so users don't have to write
their own SSE plumbing. Both support the three deployment modes
(desktop BYOK, browser BYOK, backend proxy) via `apiKey` / `baseURL` /
`headers`, and refuse `apiKey` in a browser without
`dangerouslyAllowBrowser: true`.
Also fixes a streaming-plugin bug uncovered while testing: the first
line of a streamed buffer was inserted as a plain text node, so AI
responses containing inline markdown (`**bold**`, `[link](url)`)
landed in the doc as literal characters. `applySplitBlock` now parses
the first line as inline markdown so marks survive into the doc tree.
Notable bits:
- New `@milkdown/crepe/providers/openai` and
`@milkdown/crepe/providers/anthropic` subpath exports.
- Storybook AI demo uses real providers via a vite dev proxy
configured through `viteFinal` (CORS workaround).
- Fix in `plugin-streaming/src/flush.ts` parses inline marks on the
first line.
- Drive-by fix: `feature/ai` was missing from `featureEntry` in
crepe's `rollup.config.js`, so the published JS was empty for that
subpath.
* refactor(crepe): rename providers/ subpath to llm-providers/
`providers` is too vague in the milkdown context — could be confused
with editor / kit providers. `llm-providers` is unambiguous.
- Source: src/providers/ → src/llm-providers/
- Subpath: @milkdown/crepe/providers/{openai,anthropic} →
@milkdown/crepe/llm-providers/{openai,anthropic}
- rollup + package.json exports updated accordingly
* fix(streaming): preserve plain-text whitespace and prevent sibling drop on multi-block replace
Two related fixes to applySplitBlock surfaced by the AI feature's
e2e tests:
1. parseInlineContent only adopts the parsed result when it actually
contains inline *structure* (marks or non-text nodes). Without
this, `' Inserted.'` lost its leading space (CommonMark strips
leading whitespace from paragraphs) and `'# Heading'` lost its
marker (parses as heading; extracting heading.content drops `# `).
The original behavior of preserving plain-text exactly is restored
for those cases while bold/italic/links from the prior fix still
parse correctly.
2. Extend `to` past the parent's close when the right boundary lands
exactly at the end of the enclosing inline content. The slice has
`openEnd: 0`; without the extension, ProseMirror reconciles the
depth mismatch and silently drops the next sibling block. This
manifested as e.g. an `End.` paragraph disappearing after
replacing an entire prior paragraph with multi-block content.
Also: replaces `Shift+End` in the multi-block selection e2e test
with character-counted `Shift+ArrowRight` so the test is platform
deterministic — `End` extends to end-of-document on macOS browsers
vs end-of-line on Linux/Windows.
* fix: address copilot review comments on PR #2355
- plugin-streaming: skip the markdown parser when the streamed line
contains no inline-markdown tokens (`*_~`-`[]\\`). Insert-mode
flushes happen on every push, so this restores the cheap path for
plain-text chunks that the previous parser-then-fallback approach
regressed.
- storybook: make the BYOK demo's `mount` async, await `crepe.destroy()`
during teardown, and disable Save/Clear buttons while a remount is
in flight so repeated clicks can't overlap create/destroy lifecycles.
- storybook: `Args.instance` is set asynchronously after `crepe.create()`
resolves; mark it optional so callers don't accidentally treat it as
always-present.
- storybook: correct the proxy-location comment — proxies live in
`.storybook/main.ts` via `viteFinal`, not in the user `vite.config.mts`
(Storybook's vite builder doesn't honor `server.proxy` from the
user config).
* fix: address second round of copilot review on PR #2355
- storybook: `createCrepeInstance` now actually awaits `crepe.create()`
and returns `Promise<Crepe>`. The BYOK demo's `mount()` awaits it
end-to-end, so the status only flips to "Editor ready" once the
editor is usable, and `create()` failures surface in the status
area instead of silently console.errored.
- storybook: switch the BYOK key store from `localStorage` to
`sessionStorage` so the API key clears when the tab closes (and
doesn't bleed across sessions). Banner copy updated to match.
* fix: address third round of copilot review on PR #2355
- plugin-streaming: extend the inline-markdown fast-path token regex
to include `<` so streamed autolinks (`<https://...>`,
`<a@b.com>`) and raw HTML still go through the parser instead of
being inserted as plain text.
- storybook: build the BYOK banner with `createElement` /
`textContent` instead of `innerHTML` interpolation. The dynamic
values are currently constrained by the `aiProvider` union, but
building the DOM explicitly avoids reintroducing an XSS surface
if those values ever broaden.
* fix: address fourth round of copilot review on PR #2355
`parseInlineContent` now restores leading and trailing whitespace
that CommonMark strips when wrapping inline content in a paragraph.
Without this, content like `' **bold**'` (a common pattern when an
LLM streams a leading separator space before a marked-up token)
loses the leading space and visibly collides with the preceding
character once inserted mid-paragraph.
The restore is conditional: only prepended/appended when the parsed
output's first/last text node doesn't already start/end with the
input's original whitespace, so we don't double up if the parser
preserves it.
Adds a regression test in `ai.spec.ts` exercising ' **bold**' end to
end (verifies the leading space survives and the strong mark still
applies).
* fix(llm-providers): respect empty-string systemPrompt + nest providers
- Fix the systemPrompt contract: only `null` should omit the system
message; an empty string is a caller-provided value and gets sent
as-is. Both providers were using truthy checks (`if (systemPrompt)`
in OpenAI's `defaultMessages`, `built.system ? ... : ...` in
Anthropic's request body) that silently dropped `''`. Switched to
explicit `!== null` / `!== undefined` checks. Adds unit tests for
the empty-string case in both providers.
- Refactor: each provider now lives at `llm-providers/<name>/index.ts`
rather than `llm-providers/<name>.ts`. Same subpath exports
(`@milkdown/crepe/llm-providers/openai|anthropic`), but each
provider has room to grow into multiple files (types, helpers,
per-provider utilities) without churn.
* fix: address sixth round of copilot review on PR #2355
- llm-providers/shared: broaden the browser-safety check to also
detect Worker contexts (Web/Service/Shared/Worklet). Previously
only `window.document` was checked, so a Worker could use
`apiKey` directly without `dangerouslyAllowBrowser` opt-in. The
detector now also matches when `globalThis.WorkerGlobalScope` is
defined. Made it lazy (function instead of module-level constant)
so tests can stub the relevant globals; adds tests for both the
worker case and the Node/SSR case (still allowed without opt-in).
- storybook: drop the `Args.instance` field. It was only ever
written, never read, and stashing a live `Crepe` instance on
Storybook args risks leaking a non-serializable object into the
args/controls state. The BYOK demo already keeps its `Crepe`
reference in local state. Updated the 6 theme story files to use
`Args` directly instead of `Omit<Args, 'instance'>`.
* fix: address seventh round of copilot review on PR #2355
- llm-providers/shared (parseSSE): the tail flush used `buffer.trim()`,
which strips leading/trailing whitespace from the last partial
`data:` payload. Streamed AI tokens often carry intentional
boundary spaces, so trimming silently corrupts content. Now only
strips a trailing `\r` (CRLF without an LF) and yields the rest
as-is. Adds a regression test covering a payload with significant
leading and trailing spaces.
- plugin-streaming (INLINE_MARKDOWN_TOKENS): drop `]` from the
fast-path token regex. `]` only appears in valid markdown after a
matching `[`, so checking the opener is sufficient and the closer
is redundant. Comment updated to match.
* fix: address eighth round of copilot review on PR #2355
`parseInlineContent` checked only `firstBlock?.isTextblock`, which
also matched headings and code blocks. For input like `# **bold**`
the parser produces `heading(strong('bold'))` — extracting
`heading.content` would silently drop the `# ` block marker.
Tighten the check to `firstBlock?.type.name === 'paragraph'`
specifically: any other block type (heading, code, etc.) falls
through to plain-text fallback so the streamed characters survive
verbatim. Adds a regression test in `ai.spec.ts` covering
`# **bold**`.
* fix: address ninth round of copilot review on PR #2355
- plugin-streaming: guard `resolvedTo.after(depth)` with `depth > 0`
in `applySplitBlock`. The default strategy resolver only returns
`split-block` for `depth >= 1`, but a user-defined
`streamingConfig.insertStrategy` could request `split-block` at
depth 0 — `ResolvedPos.after(0)` throws there. The guard skips
the boundary extension in that case (the call is purely a depth-
matching optimization, so skipping it just falls back to the
original `to`).
- storybook: give the BYOK key input a per-setup unique id rather
than the hard-coded `'byok-key'`. Storybook Docs renders
multiple story instances on the same page, so the previous
hard-coded id duplicated across instances and broke
label↔input association.
* test(ai): add focused regression for multi-block replace at non-zero depth
Pins the behavior of `applySplitBlock`'s `actualTo` extension: when
the AI replaces the entire inline content of a paragraph at depth >= 1
with multi-block content, the slice's `openEnd: 0` previously didn't
match the `to` boundary depth and ProseMirror silently dropped the
next sibling block.
The new test programmatically selects the whole inline content of a
"middle" paragraph in a three-paragraph doc, streams a multi-block
response, and asserts the trailing "last" paragraph survives. Uses
`TextSelection` directly so the selection isn't subject to the
platform-dependent Shift+End behavior that affected the e2e test.
* fix: address tenth round of copilot review on PR #2355
The BYOK demo's `aiModel` arg was hard-coded to `gpt-4o-mini` while
`aiProvider` was a Storybook radio control. Toggling the provider
to `anthropic` would silently send an OpenAI model id and the demo
would 4xx — unhelpful.
- Add a `DEFAULT_MODEL_FOR_PROVIDER` map in setup.ts and a
`resolveModel` helper. The buildProvider call now uses the user's
`aiModel` when set, otherwise falls back to the chosen provider's
default. Status text shows the resolved model.
- 6 theme story files: change defaultArgs `aiModel` from
`'gpt-4o-mini'` to `''` so the per-provider default kicks in,
and expose `aiModel` as an explicit text control with a
description that documents both provider defaults so users know
what they're overriding.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See Commits and Changes for more details.
Created by
pull[bot] (v2.0.0-alpha.4)
Can you help keep this open source service alive? 💖 Please sponsor : )