Skip to content

[pull] main from Milkdown:main#205

Merged
pull[bot] merged 2 commits into
code:mainfrom
Milkdown:main
May 9, 2026
Merged

[pull] main from Milkdown:main#205
pull[bot] merged 2 commits into
code:mainfrom
Milkdown:main

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented May 9, 2026

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 : )

Saul-Mirone and others added 2 commits May 9, 2026 13:49
* 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>
@pull pull Bot locked and limited conversation to collaborators May 9, 2026
@pull pull Bot added the ⤵️ pull label May 9, 2026
@pull pull Bot merged commit 84c9ea0 into code:main May 9, 2026
2 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant