文件
mynode/packages/lib/services/ai/AiService.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

189 行
7.0 KiB
TypeScript

import Setting from '../../models/Setting';
import JoplinError from '../../JoplinError';
import Logger from '@joplin/utils/Logger';
import SyncTargetRegistry from '../../SyncTargetRegistry';
import { ChatMessage, ChatOptions, ChatProvider, ChatResult, EmbeddingProvider, ProviderClassification, ProviderType } from './types';
import deriveClassification from './classification';
import ChatProviderBase from './providers/ChatProviderBase';
import OpenAiCompatibleProvider from './providers/OpenAiCompatible';
import AnthropicProvider from './providers/Anthropic';
import JoplinCloudProvider from './providers/JoplinCloud';
import TestProvider from './providers/TestProvider';
const logger = Logger.create('AiService');
// Bridge from packages/lib to a UI store without importing Redux. Desktop
// registers a dispatcher at boot; other apps leave it null and AiService
// silently no-ops. Payload is partial so callers (or the reducer) can bump
// individual fields without clobbering unrelated ones.
export interface AiStatusUpdate {
degraded?: boolean;
tokensUsed?: number;
tokensBudget?: number;
lastToastShownAt?: number | null;
}
let statusListener_: ((update: AiStatusUpdate)=> void) | null = null;
export const setAiStatusListener = (fn: ((update: AiStatusUpdate)=> void) | null) => {
statusListener_ = fn;
};
export default class AiService {
private static instance_: AiService;
public static instance(): AiService {
if (!this.instance_) this.instance_ = new AiService();
return this.instance_;
}
private cachedProvider_: ChatProvider | null = null;
private cachedProviderKey_: string | null = null;
private currentSettingsKey(): string {
// Re-builds the provider when any of these change.
return [
Setting.value('ai.chat.providerType'),
Setting.value('ai.chat.baseUrl'),
Setting.value('ai.chat.apiKey'),
Setting.value('ai.chat.model'),
].join('|');
}
private buildProvider(): ChatProvider {
const providerType = Setting.value('ai.chat.providerType') as ProviderType;
if (providerType === 'joplin-cloud') {
return this.attachRecorder(new JoplinCloudProvider());
}
if (providerType === 'anthropic') {
return this.attachRecorder(new AnthropicProvider({
apiKey: Setting.value('ai.chat.apiKey'),
model: Setting.value('ai.chat.model'),
}));
}
if (providerType === 'openai-compatible') {
const baseUrl = Setting.value('ai.chat.baseUrl');
return this.attachRecorder(new OpenAiCompatibleProvider({
baseUrl,
apiKey: Setting.value('ai.chat.apiKey'),
model: Setting.value('ai.chat.model'),
classification: deriveClassification('openai-compatible', baseUrl),
}));
}
if (providerType === 'test-provider') {
return this.attachRecorder(new TestProvider());
}
throw new JoplinError(`Unknown AI provider type: ${providerType}`, 'aiUnknownProvider');
}
private attachRecorder(provider: ChatProviderBase): ChatProvider {
provider.setUsageRecorder(async (inputTokens, outputTokens) => {
await this.recordTokens(inputTokens, outputTokens);
});
return provider;
}
private getProvider(): ChatProvider {
const key = this.currentSettingsKey();
if (this.cachedProvider_ && this.cachedProviderKey_ === key) return this.cachedProvider_;
this.cachedProvider_ = this.buildProvider();
this.cachedProviderKey_ = key;
return this.cachedProvider_;
}
public effectiveClassification(): ProviderClassification {
const providerType = Setting.value('ai.chat.providerType') as ProviderType;
const baseUrl = providerType === 'openai-compatible' ? Setting.value('ai.chat.baseUrl') : '';
return deriveClassification(providerType, baseUrl);
}
public async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
if (!Setting.value('ai.enabled')) {
throw new JoplinError('AI features are disabled. Enable them in Settings → AI.', 'aiDisabled');
}
if (!messages || !messages.length) {
throw new JoplinError('Messages array must not be empty', 'aiInvalidRequest');
}
const provider = this.getProvider();
if (provider.classification === 'remote' && !Setting.value('ai.allowRemote')) {
throw new JoplinError('Remote AI access is not allowed. Enable "Allow remote AI providers" in Settings → AI to use cloud-hosted models.', 'aiRemoteNotAllowed');
}
const result = await provider.chat(messages, options);
this.applyStatusFromResult(result);
return result;
}
// Best-effort: a listener/persistence failure must not turn a successful
// chat into a failed one.
private applyStatusFromResult(result: ChatResult) {
if (result.degraded === undefined && result.tokensUsed === undefined && result.tokensBudget === undefined) return;
const update: AiStatusUpdate = {};
if (result.degraded !== undefined) update.degraded = result.degraded;
if (result.tokensUsed !== undefined) update.tokensUsed = result.tokensUsed;
if (result.tokensBudget !== undefined) update.tokensBudget = result.tokensBudget;
try {
if (statusListener_) statusListener_(update);
} catch (error) {
logger.warn('AI status listener failed:', error);
}
try {
const current = Setting.value('ai.status') as unknown as Record<string, unknown>;
Setting.setValue('ai.status', { ...current, ...update } as never);
} catch (error) {
logger.warn('Failed to persist ai.status:', error);
}
}
// Called when ai.enabled flips from false to true. If the user has never
// made an explicit provider choice and they're on Joplin Cloud sync, switch
// the default to joplin-cloud. This is a single one-shot write — after it
// runs, sync target changes never affect the AI provider.
public applyFirstEnableDefault() {
if (Setting.value('ai.chat.providerType.configured')) return;
if (Setting.value('sync.target') === SyncTargetRegistry.nameToId('joplinCloud')) {
Setting.setValue('ai.chat.providerType', 'joplin-cloud');
logger.info('Applied first-enable default: joplin-cloud (user is on Joplin Cloud sync)');
}
Setting.setValue('ai.chat.providerType.configured', true);
}
public invalidateProvider() {
this.cachedProvider_ = null;
this.cachedProviderKey_ = null;
}
// Embedding provider — set externally by app startup (desktop will install
// the ONNX-backed local provider in a follow-up PR; tests install the
// deterministic test stub via setEmbeddingProvider() below). Returns null
// when no provider has been installed — callers (the indexer, future
// search APIs) should treat that as "embeddings unavailable" and degrade.
private embeddingProvider_: EmbeddingProvider | null = null;
public setEmbeddingProvider(provider: EmbeddingProvider | null) {
this.embeddingProvider_ = provider;
}
public getActiveEmbeddingProvider(): EmbeddingProvider | null {
return this.embeddingProvider_;
}
private async recordTokens(inputTokens: number, outputTokens: number) {
const prevIn = Setting.value('ai.usage.inputTokens') as number;
const prevOut = Setting.value('ai.usage.outputTokens') as number;
Setting.setValue('ai.usage.inputTokens', prevIn + inputTokens);
Setting.setValue('ai.usage.outputTokens', prevOut + outputTokens);
}
}