文件
mynode/packages/app-desktop/services/aiStatusBridge.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

84 行
3.4 KiB
TypeScript

import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import Logger from '@joplin/utils/Logger';
import { setAiStatusListener, AiStatusUpdate } from '@joplin/lib/services/ai/AiService';
import { ToastType } from '@joplin/lib/services/plugins/api/types';
import { AiStatus, AppState, defaultAiStatus } from '../app.reducer';
// Minimal store shape the bridge needs. The desktop app's own store is
// typed against the base State (Store<State>) even though it dispatches
// app-level actions; accepting this narrower interface avoids a caller-side
// cast at the boot site.
export interface AiStatusStore {
dispatch: (action: { type: string; [key: string]: unknown })=> unknown;
getState: ()=> AppState;
}
const logger = Logger.create('aiStatusBridge');
// Two local timestamps are on the same calendar day when their year, month
// and date match in the local timezone. Using components rather than a UTC
// diff means DST transitions don't accidentally count as a same-day match
// or push the boundary into the wrong day.
const isSameLocalDay = (a: number, b: number): boolean => {
const da = new Date(a);
const db = new Date(b);
return da.getFullYear() === db.getFullYear()
&& da.getMonth() === db.getMonth()
&& da.getDate() === db.getDate();
};
const readPersistedStatus = (): AiStatus => {
try {
const raw = Setting.value('ai.status') as Partial<AiStatus> | undefined;
if (!raw) return defaultAiStatus();
return { ...defaultAiStatus(), ...raw };
} catch (error) {
logger.warn('Failed to read ai.status:', error);
return defaultAiStatus();
}
};
// Exported for tests: takes a store and returns the same function that
// installAiStatusBridge would register on AiService. Kept separate so a test
// can drive it without spinning up the real AiService.
export const makeAiStatusListener = (store: AiStatusStore) => (update: AiStatusUpdate) => {
store.dispatch({ type: 'AI_STATUS_UPDATE', payload: update });
if (update.degraded !== true) return;
const now = Date.now();
const state = store.getState();
const last = state.aiStatus?.lastToastShownAt ?? null;
const alreadyShownToday = last !== null && isSameLocalDay(last, now);
if (alreadyShownToday) return;
// Toast content is plain text — the popup renderer only accepts a string.
// The clickable link lives in the persistent surfaces (ChatPanel, Settings)
// where JSX is available.
store.dispatch({
type: 'TOAST_SHOW',
value: {
message: _('AI usage has exceeded your monthly allowance — responses are using a reduced-quality model. Credits replenish on a rolling 30-day basis. Buy more at %s.', 'https://joplincloud.com/users/me'),
type: ToastType.Info,
},
});
store.dispatch({ type: 'AI_STATUS_UPDATE', payload: { lastToastShownAt: now } });
try {
const persisted = Setting.value('ai.status') as unknown as Record<string, unknown>;
Setting.setValue('ai.status', { ...persisted, lastToastShownAt: now } as never);
} catch (error) {
logger.warn('Failed to persist lastToastShownAt:', error);
}
};
export const installAiStatusBridge = (store: AiStatusStore) => {
// Seed the slice from the persisted setting so a plugin call made while
// the sidebar and settings screen were both closed still shows a fresh
// status the next time either is opened.
store.dispatch({ type: 'AI_STATUS_UPDATE', payload: readPersistedStatus() });
setAiStatusListener(makeAiStatusListener(store));
};
export const _internal = { isSameLocalDay, readPersistedStatus };