diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index d326ad8..44ce522 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -18,6 +18,7 @@ export class LocalOllamaProvider implements InferenceProvider { private timeoutMs: number; private temperature: number; private numPredict: number; + private abortController: AbortController | null = null; constructor(opts: LocalOllamaOptions = {}) { this.endpoint = opts.endpoint ?? 'http://localhost:11434'; @@ -29,8 +30,8 @@ export class LocalOllamaProvider implements InferenceProvider { } async generate(input: GenerateInput): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); + this.abortController = new AbortController(); + const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs); try { const res = await request(`${this.endpoint}/api/generate`, { method: 'POST', @@ -42,7 +43,7 @@ export class LocalOllamaProvider implements InferenceProvider { stream: false, options: { temperature: this.temperature, num_predict: this.numPredict } }), - signal: controller.signal + signal: this.abortController.signal }); if (res.statusCode < 200 || res.statusCode >= 300) { throw new Error(`ollama http ${res.statusCode}`); @@ -55,9 +56,15 @@ export class LocalOllamaProvider implements InferenceProvider { return parseAiResponse(parsed); } finally { clearTimeout(timer); + this.abortController = null; } } + /** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */ + abort(): void { + this.abortController?.abort(); + } + async healthCheck(): Promise { try { const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' }); diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index a9d60fb..eea7c5c 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -89,4 +89,24 @@ describe('LocalOllamaProvider', () => { expect(h.ok).toBe(false); expect(h.reason).toMatch(/connect|refused|unreachable/i); }); + + it('abort() cancels in-flight generate (rejects with AbortError)', async () => { + mock.get('http://localhost:11434').intercept({ + path: '/api/generate', method: 'POST' + }).reply((async () => { + await new Promise((r) => setTimeout(r, 5000)); // long-running + return { statusCode: 200, data: '{}' }; + }) as never); + const provider = new LocalOllamaProvider({ timeoutMs: 30_000 }); + const generatePromise = provider.generate({ + text: 'x', todayKst: '2026-05-04', dueDateCandidates: [] + }); + setTimeout(() => provider.abort(), 50); + await expect(generatePromise).rejects.toThrow(); + }); + + it('constructor uses provided model param (not just default)', () => { + const provider = new LocalOllamaProvider({ model: 'gemma4:26b' }); + expect(provider.name).toBe('local-ollama/gemma4:26b'); + }); });