Skip to content

Commit 03de962

Browse files
committed
feat(ai): P2-11 激活离线 AI 队列(生命周期引导+摘要离线入队联网重放)
1 parent 7dc119e commit 03de962

3 files changed

Lines changed: 48 additions & 4 deletions

File tree

‎client/src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useTheme } from '@/hooks/useTheme';
2020
import { db } from '@/lib/storage/database';
2121
import { soundPlayer } from '@/lib/audio/SoundPlayer';
2222
import { getAIConfig } from '@/lib/ai/config';
23+
import { OfflineAIQueueBootstrap } from '@/lib/ai/OfflineAIQueueBootstrap';
2324
import '@/stores/useSettingsStore'; // 导入以触发音效设置初始化
2425

2526
// 启动时预加载所有音效(不阻塞渲染)
@@ -114,6 +115,7 @@ function App() {
114115
<RouterProvider router={router} />
115116
<AchievementToast />
116117
<UpdateNotification />
118+
<OfflineAIQueueBootstrap />
117119
</ErrorBoundary>
118120
</SyncProvider>
119121
</AuthProvider>

‎client/src/lib/ai/AIPluginLoader.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { LocalDurationRecommender } from './LocalFallback';
1414
import { AIError } from './ai-errors';
1515
import { ensureOnline, ensureMinLength } from './aiGuards';
1616
import { getAIPlugin, getRemotePlugin, getElectronPlugin } from './aiPluginProvider';
17+
import { offlineAIQueue } from './offlineAIQueue';
1718
import type { AIPlugin, DurationHistoryData, DurationOptions, DurationResult,
1819
SummarizeResult, FlashcardResult, EvaluateResult,
1920
SummarizeOptions, FlashcardOptions, EvaluateOptions,
@@ -41,12 +42,23 @@ class AIPluginLoader {
4142
/**
4243
* 统一非流式调用包装:守卫 → 取插件 → 能力检查 → 调用
4344
* invoke 返回 undefined 表示当前插件未实现该可选能力
45+
* @ai-context P2-11:opts.offline 提供离线入队描述(feature/endpoint/payload)时,
46+
* 离线不再单纯报错,而是 fire-and-forget 入队(联网后自动重放)后再抛 offline 提示。
4447
*/
4548
private async call<T>(
4649
invoke: (plugin: AIPlugin) => Promise<T> | undefined,
47-
opts: { contentCheck?: string; unsupportedMsg: string },
50+
opts: { contentCheck?: string; unsupportedMsg: string; offline?: { feature: string; endpoint: string; payload: unknown } },
4851
): Promise<T> {
49-
ensureOnline();
52+
try {
53+
ensureOnline();
54+
} catch (err) {
55+
if (opts.offline && err instanceof AIError && err.code === 'offline') {
56+
// 离线入队(不阻塞、不报错,联网后自动重放;测试环境无 indexedDB 时静默失败)
57+
offlineAIQueue.enqueue(opts.offline.feature, opts.offline.endpoint, opts.offline.payload).catch(() => {});
58+
throw new AIError('当前处于离线状态,请求已加入队列,联网后自动完成', 'offline', false);
59+
}
60+
throw err;
61+
}
5062
ensureMinLength(opts.contentCheck);
5163
const plugin = await getAIPlugin();
5264
const result = invoke(plugin);
@@ -75,10 +87,15 @@ class AIPluginLoader {
7587

7688
// ── 非流式功能 ─────────────────────────────────────────
7789

78-
/** 摘要功能 */
90+
/** 摘要功能(P2-11:离线入队,联网后自动重放) */
7991
async summarizeNote(content: string, options?: SummarizeOptions): Promise<SummarizeResult> {
8092
return this.call(p => p.summarizeNote(content, options),
81-
{ contentCheck: content, unsupportedMsg: '当前 AI 插件不支持摘要' });
93+
{ contentCheck: content, unsupportedMsg: '当前 AI 插件不支持摘要',
94+
offline: {
95+
feature: 'summarize',
96+
endpoint: '/api/v1/ai/summarize',
97+
payload: { text: content, options: { max_length: options?.maxLength, style: options?.style, language: options?.language } },
98+
} });
8299
}
83100

84101
/** 闪卡生成 */
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* OfflineAIQueueBootstrap — 离线 AI 队列引导(P2-11)
3+
*
4+
* 挂载于 ToastProvider 内,激活 offlineAIQueue:
5+
* - registerQueueToast:把应用 toast 注入队列模块(入队/重放成功/失败提示);
6+
* - startAutoProcess:注册 window online 监听 + 启动时在线即消费积压请求。
7+
* 渲染为 null,纯副作用组件。
8+
*
9+
* @ai-context: hook 层。offlineAIQueue 为独立 Dexie 库 'keban-ai-queue'(品牌豁免,
10+
* 不可改名);本组件是其唯一激活点,移除则离线队列退回死代码。
11+
*/
12+
import { useEffect } from 'react';
13+
import { useToast } from '@/components/ui/Toast';
14+
import { offlineAIQueue } from '@/lib/ai/offlineAIQueue';
15+
16+
export function OfflineAIQueueBootstrap() {
17+
const { toast } = useToast();
18+
19+
useEffect(() => {
20+
offlineAIQueue.registerQueueToast(toast);
21+
offlineAIQueue.startAutoProcess();
22+
}, [toast]);
23+
24+
return null;
25+
}

0 commit comments

Comments
 (0)