一些检查失败了
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
161 行
6.5 KiB
TypeScript
161 行
6.5 KiB
TypeScript
import Setting from '../../../models/Setting';
|
|
import JoplinError from '../../../JoplinError';
|
|
import SyncTargetRegistry from '../../../SyncTargetRegistry';
|
|
import { ChatMessage, ChatOptions, ChatResult, ProviderClassification } from '../types';
|
|
import OpenAiCompatibleProvider, { ChatRequestOptions, OpenAiChatResponse } from './OpenAiCompatible';
|
|
import { msleep, Second } from '@joplin/utils/time';
|
|
import Logger from '@joplin/utils/Logger';
|
|
import { reg } from '../../../registry';
|
|
import SyncTargetJoplinCloud from '../../../SyncTargetJoplinCloud';
|
|
import FileApiDriverJoplinServer from '../../../file-api-driver-joplinServer';
|
|
|
|
const logger = Logger.create('ai/providers/JoplinCloud');
|
|
|
|
const joplinCloudSyncTarget = () => SyncTargetRegistry.nameToId('joplinCloud');
|
|
|
|
interface JoplinCloudUsage {
|
|
prompt_tokens?: number;
|
|
completion_tokens?: number;
|
|
}
|
|
|
|
interface JoplinCloudChoice {
|
|
message?: { content?: string };
|
|
}
|
|
|
|
interface JoplinCloudResponse {
|
|
choices?: JoplinCloudChoice[];
|
|
usage?: JoplinCloudUsage;
|
|
joplin?: { degraded?: boolean; tokens_used?: number; tokens_budget?: number };
|
|
}
|
|
|
|
interface JoplinCloudChatRequestOptions extends ChatRequestOptions {
|
|
retry?: number;
|
|
}
|
|
|
|
// Maps the server-side errors thrown by the Joplin Cloud AI route to messages
|
|
// the user can act on. See joplin-server/packages/server/src/routes/api/ai.ts.
|
|
//
|
|
// Code match wins over status match; codes are camelCase, matching the
|
|
// ErrorCode enum in packages/server/src/utils/errors.ts.
|
|
export const mapErrorByCode = (code: string | number | null, status: number, detail: string): JoplinError => {
|
|
if (code === 'aiRateLimitExceeded') return new JoplinError('You\'re sending requests too quickly. Try again in a moment.', code);
|
|
if (code === 'aiBudgetExhausted') return new JoplinError('You\'ve reached your AI usage budget for this billing period.', code);
|
|
if (code === 'aiAccountDisabled') return new JoplinError('AI access is disabled for your account. Contact support.', code);
|
|
if (code === 'aiUpstreamError') return new JoplinError('The AI provider is temporarily unavailable. Try again shortly.', code);
|
|
|
|
if (status === 401) return new JoplinError('Please sign in to use AI features.', status);
|
|
if (status === 501) return new JoplinError('AI is not enabled on this server.', status);
|
|
|
|
// Unknown string code (e.g. a code added server-side that this client
|
|
// version doesn't know yet): preserve it on both the message and the
|
|
// error's `code` field so callers switching on error.code and log
|
|
// scrapers looking at the message can still distinguish it.
|
|
if (typeof code === 'string') {
|
|
return new JoplinError(`Joplin Cloud AI returned ${code}${detail ? `: ${detail}` : ''}`, code);
|
|
}
|
|
|
|
return new JoplinError(`Joplin Cloud AI returned ${status}${detail ? `: ${detail}` : ''}`, status);
|
|
};
|
|
|
|
// If too many events are received in a short window, Joplin Cloud starts rejecting events.
|
|
// For now, avoid more than one event every second or two.
|
|
const minimumTimeBetweenEvents = Second;
|
|
|
|
type ApiError = Error & { code?: number|string; retryAfterSeconds?: number };
|
|
|
|
const isRateLimitError = (error: ApiError) => (
|
|
(error.code === 429 || error.code === 'aiRateLimitExceeded') && typeof error.retryAfterSeconds === 'number'
|
|
);
|
|
|
|
const canAutoRetryError = (error: ApiError) => {
|
|
return isRateLimitError(error) && error.retryAfterSeconds < 10;
|
|
};
|
|
|
|
export default class JoplinCloudProvider extends OpenAiCompatibleProvider {
|
|
|
|
public id = 'joplin-cloud';
|
|
public classification: ProviderClassification = 'remote';
|
|
private lastEventTime_ = 0;
|
|
private lastJoplinMeta_: JoplinCloudResponse['joplin'] | null = null;
|
|
|
|
public constructor() {
|
|
super({
|
|
baseUrl: '',
|
|
model: 'joplin-cloud',
|
|
classification: 'remote',
|
|
apiKey: '',
|
|
});
|
|
}
|
|
|
|
private async api() {
|
|
// Fetch the API from the registry, rather than caching the API locally.
|
|
// This avoids authentication drift if the user changes the sync target credentials.
|
|
// Note that recreating the API each time can cause the user to quickly encounter login rate limiting.
|
|
const id = joplinCloudSyncTarget();
|
|
const syncTarget = reg.syncTarget(id) as SyncTargetJoplinCloud;
|
|
const driver: FileApiDriverJoplinServer = (await syncTarget.fileApi()).driver();
|
|
const api = driver.api();
|
|
return api;
|
|
}
|
|
|
|
protected override async sendChatRequest(body: Record<string, unknown>, options: JoplinCloudChatRequestOptions): Promise<OpenAiChatResponse> {
|
|
if (Setting.value('sync.target') !== joplinCloudSyncTarget()) {
|
|
throw new JoplinError('Joplin Cloud AI requires Joplin Cloud sync', 'aiJoplinCloudSyncRequired');
|
|
}
|
|
|
|
const timeSinceLastEvent = Date.now() - this.lastEventTime_;
|
|
if (timeSinceLastEvent < minimumTimeBetweenEvents) {
|
|
await msleep(minimumTimeBetweenEvents - timeSinceLastEvent);
|
|
}
|
|
|
|
const api = await this.api();
|
|
|
|
// JoplinServerApi.exec() returns the parsed JSON object directly when
|
|
// the response format is JSON (the default). No need to JSON.parse.
|
|
let json: JoplinCloudResponse;
|
|
try {
|
|
const execOptions = {
|
|
signal: options.signal,
|
|
// Rate limit errors happen during normal use and don't need to be logged as warnings
|
|
ignoreError: isRateLimitError,
|
|
};
|
|
json = await api.exec(
|
|
'POST', 'api/ai/chat/completions', null, body, null, execOptions,
|
|
) as JoplinCloudResponse;
|
|
} catch (error) {
|
|
const retry = options.retry ?? 0;
|
|
if (canAutoRetryError(error) && retry < 3) {
|
|
logger.info('Retrying AI request after', error.retryAfterSeconds, 's...');
|
|
await msleep(error.retryAfterSeconds * Second);
|
|
return this.sendChatRequest(body, { ...options, retry: retry + 1 });
|
|
}
|
|
|
|
// JoplinServerApi stores the server's `code` field on error.code
|
|
// (string) and falls back to the HTTP status (number) when no code
|
|
// was returned. Preserve that distinction.
|
|
const rawCode = error?.code;
|
|
const code = typeof rawCode === 'string' ? rawCode : null;
|
|
const status = typeof rawCode === 'number' ? rawCode : 0;
|
|
const detail = error?.message ?? '';
|
|
throw mapErrorByCode(code, status, detail);
|
|
}
|
|
|
|
this.lastEventTime_ = Date.now();
|
|
this.lastJoplinMeta_ = json?.joplin ?? null;
|
|
|
|
return { response: { status: 200 }, json };
|
|
}
|
|
|
|
protected override async doChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
|
this.lastJoplinMeta_ = null;
|
|
const result = await super.doChat(messages, options);
|
|
const meta = this.lastJoplinMeta_;
|
|
if (meta) {
|
|
if (typeof meta.degraded === 'boolean') result.degraded = meta.degraded;
|
|
if (typeof meta.tokens_used === 'number') result.tokensUsed = meta.tokens_used;
|
|
if (typeof meta.tokens_budget === 'number') result.tokensBudget = meta.tokens_budget;
|
|
}
|
|
return result;
|
|
}
|
|
}
|