From 2cd5c06191f38e565691a6984022f4c4193608bc Mon Sep 17 00:00:00 2001 From: Mirone Date: Sun, 19 Apr 2026 23:31:36 +0900 Subject: [PATCH] feat: expose onError callback for AI feature (#2338) * feat: expose onError callback for AI feature * [autofix.ci] apply automated fixes * refactor: expose exception through kit instead of direct dependency * [autofix.ci] apply automated fixes * refactor: use console.error as default onError handler * docs: add onError to AI feature example * fix: guard onError calls and preserve error cause * refactor: extract emitAIError helper, spread prev config, use stringify for cause * fix: safe stringify in error factories, narrow emitAIError try/catch, add tests * fix: explicitly assign cause for ES2018 compatibility * fix: rename flush helper, type code as ErrorCode --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- docs/api/crepe.md | 5 ++ packages/crepe/src/feature/ai/ai.spec.ts | 74 +++++++++++++++++++++++ packages/crepe/src/feature/ai/commands.ts | 24 +++++++- packages/crepe/src/feature/ai/index.ts | 14 +++-- packages/crepe/src/feature/ai/types.ts | 6 ++ packages/exception/src/code.ts | 4 ++ packages/exception/src/error.ts | 10 ++- packages/exception/src/index.ts | 29 +++++++++ packages/kit/package.json | 9 +++ packages/kit/src/exception.ts | 1 + packages/kit/tsconfig.json | 1 + pnpm-lock.yaml | 3 + 12 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 packages/crepe/src/feature/ai/ai.spec.ts create mode 100644 packages/kit/src/exception.ts diff --git a/docs/api/crepe.md b/docs/api/crepe.md index 4cc778b01d3..f7a2110256c 100644 --- a/docs/api/crepe.md +++ b/docs/api/crepe.md @@ -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, }, }) diff --git a/packages/crepe/src/feature/ai/ai.spec.ts b/packages/crepe/src/feature/ai/ai.spec.ts new file mode 100644 index 00000000000..787a324a2b0 --- /dev/null +++ b/packages/crepe/src/feature/ai/ai.spec.ts @@ -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((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) + }) +}) diff --git a/packages/crepe/src/feature/ai/commands.ts b/packages/crepe/src/feature/ai/commands.ts index 8b4215056ee..a34d91db404 100644 --- a/packages/crepe/src/feature/ai/commands.ts +++ b/packages/crepe/src/feature/ai/commands.ts @@ -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, @@ -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' ) @@ -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 // --------------------------------------------------------------------------- @@ -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 { @@ -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 } diff --git a/packages/crepe/src/feature/ai/index.ts b/packages/crepe/src/feature/ai/index.ts index b713fee948b..960f80d181c 100644 --- a/packages/crepe/src/feature/ai/index.ts +++ b/packages/crepe/src/feature/ai/index.ts @@ -68,10 +68,16 @@ export const ai: DefineFeature = (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) diff --git a/packages/crepe/src/feature/ai/types.ts b/packages/crepe/src/feature/ai/types.ts index 0f74793d05b..c08e3ccd3ef 100644 --- a/packages/crepe/src/feature/ai/types.ts +++ b/packages/crepe/src/feature/ai/types.ts @@ -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 { @@ -46,6 +47,11 @@ export interface AIFeatureConfig { /// `AIFeatureConfig.diffReviewOnEnd` at the AI layer — setting it on /// both would be confusing. streaming?: Partial> + + /// Called when an error occurs during AI processing. + /// Receives a `MilkdownError` with code `aiProviderError` or + /// `aiBuildContextError`. + onError?: (error: MilkdownError) => void } /// Options passed to `runAICmd`. diff --git a/packages/exception/src/code.ts b/packages/exception/src/code.ts index 302a65be762..8e0e1b4bf87 100644 --- a/packages/exception/src/code.ts +++ b/packages/exception/src/code.ts @@ -17,4 +17,8 @@ export enum ErrorCode { // collab plugin ctxNotBind = 'ctxNotBind', missingYjsDoc = 'missingYjsDoc', + + // AI plugin + aiProviderError = 'aiProviderError', + aiBuildContextError = 'aiBuildContextError', } diff --git a/packages/exception/src/error.ts b/packages/exception/src/error.ts index 2a9111f4ef4..5c6638840b5 100644 --- a/packages/exception/src/error.ts +++ b/packages/exception/src/error.ts @@ -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 + } } } diff --git a/packages/exception/src/index.ts b/packages/exception/src/index.ts index f2ab5668c9b..646241c9e74 100644 --- a/packages/exception/src/index.ts +++ b/packages/exception/src/index.ts @@ -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 @@ -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 } + ) +} diff --git a/packages/kit/package.json b/packages/kit/package.json index 59f78be9e8c..a035c9f4970 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -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", @@ -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" @@ -247,6 +252,9 @@ "core": [ "lib/core.d.ts" ], + "exception": [ + "lib/exception.d.ts" + ], "ctx": [ "lib/ctx.d.ts" ], @@ -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:*", diff --git a/packages/kit/src/exception.ts b/packages/kit/src/exception.ts new file mode 100644 index 00000000000..63229d6d426 --- /dev/null +++ b/packages/kit/src/exception.ts @@ -0,0 +1 @@ +export * from '@milkdown/exception' diff --git a/packages/kit/tsconfig.json b/packages/kit/tsconfig.json index 9f956d51833..f00cfa67bf1 100644 --- a/packages/kit/tsconfig.json +++ b/packages/kit/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../components" }, { "path": "../core" }, { "path": "../ctx" }, + { "path": "../exception" }, { "path": "../plugins/plugin-block" }, { "path": "../plugins/plugin-clipboard" }, { "path": "../plugins/plugin-cursor" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c83e24fc992..d83dcf79e39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -544,6 +544,9 @@ importers: '@milkdown/ctx': specifier: workspace:* version: link:../ctx + '@milkdown/exception': + specifier: workspace:* + version: link:../exception '@milkdown/plugin-block': specifier: workspace:* version: link:../plugins/plugin-block