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
5 changes: 5 additions & 0 deletions docs/api/crepe.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,11 @@ const crepe = new Crepe({
diffReviewOnEnd: true,
diff: { acceptLabel: 'Yes', rejectLabel: 'No' },
streaming: { throttleMs: 150 },
onError: (error) => {
// Handle AI errors (provider failures, buildContext errors).
// Defaults to console.error if not provided.
showToast(error.message)
},
} satisfies AIFeatureConfig,
},
})
Expand Down
74 changes: 74 additions & 0 deletions packages/crepe/src/feature/ai/ai.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { callCommand } from '@milkdown/kit/utils'
import { describe, expect, test, vi } from 'vitest'

import { Crepe } from '../../core'
import { CrepeFeature } from '../index'
import { runAICmd } from './commands'

function waitForAsync() {
return new Promise<void>((resolve) => setTimeout(resolve, 0))
}

describe('AI onError', () => {
test('provider error triggers onError with aiProviderError code', async () => {
const onError = vi.fn()

const crepe = new Crepe({
features: {
[CrepeFeature.AI]: true,
},
featureConfigs: {
[CrepeFeature.AI]: {
provider: async function* () {
yield 'partial'
throw new Error('network failure')
},
onError,
diffReviewOnEnd: false,
},
},
})
await crepe.create()

crepe.editor.action(callCommand(runAICmd.key, { instruction: 'test' }))
await waitForAsync()

expect(onError).toHaveBeenCalledOnce()
const error = onError.mock.calls[0]![0]
expect(error.code).toBe('aiProviderError')
expect(error.message).toContain('network failure')
expect(error.cause).toBeInstanceOf(Error)
})

test('buildContext error triggers onError with aiBuildContextError code', async () => {
const onError = vi.fn()

const crepe = new Crepe({
features: {
[CrepeFeature.AI]: true,
},
featureConfigs: {
[CrepeFeature.AI]: {
provider: async function* () {
yield 'hello'
},
buildContext: () => {
throw new Error('context build failed')
},
onError,
diffReviewOnEnd: false,
},
},
})
await crepe.create()

crepe.editor.action(callCommand(runAICmd.key, { instruction: 'test' }))
await waitForAsync()

expect(onError).toHaveBeenCalledOnce()
const error = onError.mock.calls[0]![0]
expect(error.code).toBe('aiBuildContextError')
expect(error.message).toContain('context build failed')
expect(error.cause).toBeInstanceOf(Error)
})
})
24 changes: 22 additions & 2 deletions packages/crepe/src/feature/ai/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Ctx } from '@milkdown/kit/ctx'
import type { MilkdownError } from '@milkdown/kit/exception'

import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
import { aiBuildContextError, aiProviderError } from '@milkdown/kit/exception'
import { diffPluginKey } from '@milkdown/kit/plugin/diff'
import {
abortStreamingCmd,
Expand Down Expand Up @@ -28,6 +30,9 @@ export const aiProviderConfig = $ctx(
| ((ctx: Ctx, instruction: string) => AIPromptContext)
| undefined,
diffReviewOnEnd: true,
onError: (error: MilkdownError) => {
console.error(`[milkdown/ai] [${error.code}]`, error)
},
},
'aiProviderConfig'
)
Expand Down Expand Up @@ -56,6 +61,19 @@ function setStreamingClass(ctx: Ctx, active: boolean): void {
}
}

// ---------------------------------------------------------------------------
// Error helper
// ---------------------------------------------------------------------------

function emitAIError(ctx: Ctx, error: MilkdownError): void {
const config = ctx.get(aiProviderConfig.key)
try {
config.onError(error)
} catch (handlerError) {
console.error('[milkdown/ai] onError handler failed:', handlerError)
}
}

// ---------------------------------------------------------------------------
// Async provider runner
// ---------------------------------------------------------------------------
Expand All @@ -81,7 +99,8 @@ async function runProvider(
})
} catch (error) {
if (abortController.signal.aborted) return
console.error('[milkdown/ai] Provider error:', error)
const milkdownError = aiProviderError(error)
emitAIError(ctx, milkdownError)
const commands = ctx.get(commandsCtx)
commands.call(abortStreamingCmd.key, { keep: false })
} finally {
Expand Down Expand Up @@ -142,7 +161,8 @@ export const runAICmd = $command('RunAI', (ctx) => {
const buildContext = config.buildContext ?? defaultBuildContext
promptContext = buildContext(ctx, options.instruction)
} catch (error) {
console.error('[milkdown/ai] buildContext failed:', error)
const milkdownError = aiBuildContextError(error)
emitAIError(ctx, milkdownError)
commands.call(abortStreamingCmd.key, { keep: false })
return false
}
Expand Down
14 changes: 10 additions & 4 deletions packages/crepe/src/feature/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,16 @@ export const ai: DefineFeature<AIFeatureConfig> = (editor, config) => {
.use(streaming)
// -- AI orchestration --
.config((ctx) => {
ctx.update(aiProviderConfig.key, () => ({
provider: config?.provider,
buildContext: config?.buildContext,
diffReviewOnEnd: config?.diffReviewOnEnd ?? true,
ctx.update(aiProviderConfig.key, (prev) => ({
...prev,
...(config?.provider !== undefined
? { provider: config.provider }
: {}),
...(config?.buildContext !== undefined
? { buildContext: config.buildContext }
: {}),
diffReviewOnEnd: config?.diffReviewOnEnd ?? prev.diffReviewOnEnd,
...(config?.onError !== undefined ? { onError: config.onError } : {}),
}))
})
.use(aiProviderConfig)
Expand Down
6 changes: 6 additions & 0 deletions packages/crepe/src/feature/ai/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Ctx } from '@milkdown/kit/ctx'
import type { MilkdownError } from '@milkdown/kit/exception'
import type { StreamingConfig } from '@milkdown/kit/plugin/streaming'

export interface AIPromptContext {
Expand Down Expand Up @@ -46,6 +47,11 @@ export interface AIFeatureConfig {
/// `AIFeatureConfig.diffReviewOnEnd` at the AI layer — setting it on
/// both would be confusing.
streaming?: Partial<Omit<StreamingConfig, 'diffReviewOnEnd'>>

/// Called when an error occurs during AI processing.
/// Receives a `MilkdownError` with code `aiProviderError` or
/// `aiBuildContextError`.
onError?: (error: MilkdownError) => void
}

/// Options passed to `runAICmd`.
Expand Down
4 changes: 4 additions & 0 deletions packages/exception/src/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ export enum ErrorCode {
// collab plugin
ctxNotBind = 'ctxNotBind',
missingYjsDoc = 'missingYjsDoc',

// AI plugin
aiProviderError = 'aiProviderError',
aiBuildContextError = 'aiBuildContextError',
}
10 changes: 7 additions & 3 deletions packages/exception/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { ErrorCode } from './code'

export class MilkdownError extends Error {
public code: string
constructor(code: ErrorCode, message: string) {
super(message)
public readonly code: ErrorCode
public override cause?: unknown
constructor(code: ErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'MilkdownError'
this.code = code
if (options?.cause !== undefined) {
this.cause = options.cause
}
}
}
29 changes: 29 additions & 0 deletions packages/exception/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ErrorCode } from './code'
import { MilkdownError } from './error'

export { ErrorCode } from './code'
export { MilkdownError } from './error'

const functionReplacer = (_: string, value: unknown) =>
typeof value === 'function' ? '[Function]' : value

Expand Down Expand Up @@ -184,3 +187,29 @@ export function missingYjsDoc() {
'Missing yjs doc, please make sure you have bind one.'
)
}

function safeStringify(value: unknown): string {
try {
return stringify(value)
} catch {
return '[Unserializable]'
}
}

export function aiProviderError(cause: unknown) {
const message = cause instanceof Error ? cause.message : safeStringify(cause)
return new MilkdownError(
ErrorCode.aiProviderError,
`AI provider error: ${message}`,
{ cause }
)
}

export function aiBuildContextError(cause: unknown) {
const message = cause instanceof Error ? cause.message : safeStringify(cause)
return new MilkdownError(
ErrorCode.aiBuildContextError,
`AI buildContext failed: ${message}`,
{ cause }
)
}
9 changes: 9 additions & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"exports": {
".": "./src/index.ts",
"./core": "./src/core.ts",
"./exception": "./src/exception.ts",
"./ctx": "./src/ctx.ts",
"./transformer": "./src/transformer.ts",
"./utils": "./src/utils.ts",
Expand Down Expand Up @@ -77,6 +78,10 @@
"types": "./lib/core.d.ts",
"import": "./lib/core.js"
},
"./exception": {
"types": "./lib/exception.d.ts",
"import": "./lib/exception.js"
},
"./ctx": {
"types": "./lib/ctx.d.ts",
"import": "./lib/ctx.js"
Expand Down Expand Up @@ -247,6 +252,9 @@
"core": [
"lib/core.d.ts"
],
"exception": [
"lib/exception.d.ts"
],
"ctx": [
"lib/ctx.d.ts"
],
Expand Down Expand Up @@ -374,6 +382,7 @@
"@milkdown/components": "workspace:*",
"@milkdown/core": "workspace:*",
"@milkdown/ctx": "workspace:*",
"@milkdown/exception": "workspace:*",
"@milkdown/plugin-block": "workspace:*",
"@milkdown/plugin-clipboard": "workspace:*",
"@milkdown/plugin-cursor": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@milkdown/exception'
1 change: 1 addition & 0 deletions packages/kit/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
{ "path": "../components" },
{ "path": "../core" },
{ "path": "../ctx" },
{ "path": "../exception" },
{ "path": "../plugins/plugin-block" },
{ "path": "../plugins/plugin-clipboard" },
{ "path": "../plugins/plugin-cursor" },
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading