Skip to content

Commit 4dbb92e

Browse files
committed
fix(pwa): 修复逻辑审查三处问题(尾段丢失/暂停未接、视频上传401/429、剪藏截断)
1 parent 0885f86 commit 4dbb92e

5 files changed

Lines changed: 76 additions & 65 deletions

File tree

‎client/src/features/classroom/capture/WebCaptureAdapter.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export class WebCaptureAdapter {
2828
private stream: MediaStream | null = null;
2929
private recorder: MediaRecorder | null = null;
3030
private audioCtx: AudioContext | null = null;
31-
private sessionId: string | null = null;
3231
private segmentStartAt = 0;
3332

3433
/** 是否正在录制 */
@@ -41,7 +40,6 @@ export class WebCaptureAdapter {
4140
* @param sessionId - 会话 ID(emit 音频段时透传,供下游校验会话一致性)
4241
*/
4342
async start(sessionId: string): Promise<void> {
44-
this.sessionId = sessionId;
4543
this.stream = await navigator.mediaDevices.getUserMedia({
4644
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
4745
});
@@ -50,8 +48,9 @@ export class WebCaptureAdapter {
5048
const mimeType = pickSupportedMimeType();
5149
this.recorder = new MediaRecorder(this.stream, mimeType ? { mimeType } : undefined);
5250
this.segmentStartAt = Date.now();
51+
const sid = sessionId;
5352
this.recorder.ondataavailable = (ev) => {
54-
if (ev.data.size > 0) void this.handleChunk(ev.data);
53+
if (ev.data.size > 0) void this.handleChunk(ev.data, sid);
5554
};
5655
this.recorder.start(SEGMENT_MS);
5756
}
@@ -64,13 +63,24 @@ export class WebCaptureAdapter {
6463
this.recorder = null;
6564
this.stream = null;
6665
this.audioCtx = null;
67-
this.sessionId = null;
6866
}
6967

70-
/** 解码单个分段 → 重采样 → 编码 → 广播音频段(失败静默,不影响采集) */
71-
private async handleChunk(blob: Blob): Promise<void> {
72-
const sid = this.sessionId;
73-
if (!sid || !this.audioCtx) return;
68+
/** 暂停录制(flush 当前分段后暂停,恢复后继续下一分段) */
69+
pause(): void {
70+
try { this.recorder?.pause(); } catch { /* 已停止 */ }
71+
}
72+
73+
/** 恢复录制 */
74+
resume(): void {
75+
try { this.recorder?.resume(); } catch { /* 已停止 */ }
76+
}
77+
78+
/** 解码单个分段 → 重采样 → 编码 → 广播音频段(失败静默,不影响采集)
79+
* @ai-context: sessionId 由 ondataavailable 闭包捕获传入——stop() 清空
80+
* 实例字段不影响已在队列中的尾段(修复停止时 <5s 尾段丢失,TD-005)。
81+
*/
82+
private async handleChunk(blob: Blob, sessionId: string): Promise<void> {
83+
if (!sessionId || !this.audioCtx) return;
7484
try {
7585
const arrayBuffer = await blob.arrayBuffer();
7686
const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
@@ -85,7 +95,7 @@ export class WebCaptureAdapter {
8595
energy: computeRms(pcm),
8696
};
8797
this.segmentStartAt = Date.now();
88-
captureEventBus.emit('smart:audio_segment_ready', { sessionId: sid, segment });
98+
captureEventBus.emit('smart:audio_segment_ready', { sessionId, segment });
8999
} catch (err) {
90100
console.warn('[WebCaptureAdapter] 分段解码失败(已跳过该段):', err);
91101
}

‎client/src/features/classroom/hooks/useSessionControl.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,17 @@ export function useSessionControl({
213213
}, [selectedWindow, setStatus, session, capturePath, captureManager, config, mode, courseMeta, onAudioSourceResolved, setStreamingAsrActive]);
214214

215215
const handlePause = useCallback(() => {
216+
// PWA:暂停/恢复麦克风采集(Electron 走 captureManager 会话暂停)
217+
if (!window.electronAPI) {
218+
if (status === 'capturing') {
219+
setStatus('paused');
220+
webCaptureAdapter.pause();
221+
} else if (status === 'paused') {
222+
setStatus('capturing');
223+
webCaptureAdapter.resume();
224+
}
225+
return;
226+
}
216227
if (status === 'capturing') {
217228
setStatus('paused');
218229
captureManager.pauseSession();

‎client/src/features/classroom/lib/uploadVideoAnalysis.ts‎

Lines changed: 26 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
* IPC ai_video_analyze path; this function only serves PWA/browser File upload.
1414
* Includes Douyin link detection to guide "save to album then import" (plan A).
1515
*/
16-
import { supabase } from '@/lib/auth/supabaseClient';
17-
import { requireGatewayUrl } from '@/lib/ai/config';
16+
import { aiClient } from '@/lib/http/apiClient';
1817
import { classroomNoteStore } from '@/lib/storage/classroomNoteStore';
1918
import type { AnalyzeResult } from '@/lib/ai/sessionAnalyzer';
2019

@@ -40,61 +39,39 @@ export async function analyzeVideoFile(
4039
file: File,
4140
options?: { duration?: number; language?: string },
4241
): Promise<AnalyzeResult> {
43-
const { data: { session } } = await supabase.auth.getSession();
44-
const token = session?.access_token;
45-
const baseUrl = requireGatewayUrl();
46-
4742
const formData = new FormData();
4843
formData.append('video_file', file, file.name);
4944
if (options?.duration !== undefined) formData.append('duration', String(options.duration));
5045
if (options?.language) formData.append('language', options.language);
5146

52-
const headers = new Headers();
53-
if (token) headers.set('Authorization', `Bearer ${token}`);
54-
headers.set('X-Request-ID', crypto.randomUUID());
55-
// 不显式设置 Content-Type:由浏览器为 FormData 自动生成 multipart boundary
47+
// 复用 aiClient.post:自动注入 Authorization + 401 刷新 + 429 配额提示(TD-006)
48+
const data = await aiClient.post<{
49+
content: string;
50+
keyframes_analyzed: number;
51+
model_used: string;
52+
}>('/api/v1/multimodal/analyze-video', formData, { timeout: 300000 });
5653

57-
const controller = new AbortController();
58-
const timeoutId = setTimeout(() => controller.abort(), 300000); // 与网关 300s 超时对齐
54+
const analyzeResult: AnalyzeResult = {
55+
content: data.content,
56+
keyframesAnalyzed: data.keyframes_analyzed,
57+
modelUsed: data.model_used,
58+
source: 'remote',
59+
};
5960

61+
// 自动持久化(Dexie,PWA/Electron 通用)
6062
try {
61-
const resp = await fetch(`${baseUrl}/api/v1/multimodal/analyze-video`, {
62-
method: 'POST',
63-
headers,
64-
body: formData,
65-
signal: controller.signal,
63+
await classroomNoteStore.create({
64+
sessionId: crypto.randomUUID(),
65+
title: `视频笔记 ${new Date().toLocaleString('zh-CN')}`,
66+
content: analyzeResult.content,
67+
keyframesAnalyzed: analyzeResult.keyframesAnalyzed,
68+
modelUsed: analyzeResult.modelUsed,
69+
sourceType: 'video',
70+
duration: options?.duration ?? 0,
6671
});
67-
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
68-
const data = await resp.json() as {
69-
content: string;
70-
keyframes_analyzed: number;
71-
model_used: string;
72-
};
73-
74-
const analyzeResult: AnalyzeResult = {
75-
content: data.content,
76-
keyframesAnalyzed: data.keyframes_analyzed,
77-
modelUsed: data.model_used,
78-
source: 'remote',
79-
};
80-
81-
// 自动持久化(Dexie,PWA/Electron 通用)
82-
try {
83-
await classroomNoteStore.create({
84-
sessionId: crypto.randomUUID(),
85-
title: `视频笔记 ${new Date().toLocaleString('zh-CN')}`,
86-
content: analyzeResult.content,
87-
keyframesAnalyzed: analyzeResult.keyframesAnalyzed,
88-
modelUsed: analyzeResult.modelUsed,
89-
sourceType: 'video',
90-
duration: options?.duration ?? 0,
91-
});
92-
} catch (e) {
93-
console.warn('[uploadVideoAnalysis] 视频笔记持久化失败:', e);
94-
}
95-
96-
return analyzeResult;
97-
} finally {
98-
clearTimeout(timeoutId);
72+
} catch (e) {
73+
console.warn('[uploadVideoAnalysis] 视频笔记持久化失败:', e);
9974
}
75+
76+
return analyzeResult;
10077
}

‎client/src/features/notes/hooks/useClipImport.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { useCallback, useState } from 'react';
99
import { useToast } from '@/components/ui';
1010
import { useNoteStore } from '../store/useNoteStore';
1111

12+
/** 浏览器剪藏正文截断上限(字符):防超大网页 innerText 数 MB 入内存/IndexedDB(TD-007) */
13+
const MAX_CLIP_TEXT_CHARS = 100_000;
14+
1215
export function useClipImport() {
1316
const createNote = useNoteStore((s) => s.createNote);
1417
const selectedFolderId = useNoteStore((s) => s.selectedFolderId);
@@ -40,7 +43,7 @@ export function useClipImport() {
4043
const html = await resp.text();
4144
const doc = new DOMParser().parseFromString(html, 'text/html');
4245
title = doc.title || new URL(clipUrl.trim()).hostname;
43-
text = doc.body?.innerText?.trim() ?? '';
46+
text = (doc.body?.innerText ?? '').trim().slice(0, MAX_CLIP_TEXT_CHARS);
4447
if (!text) throw new Error('empty page');
4548
}
4649
await createNote({ title: title.slice(0, 100) || '未命名剪藏', content: text, template: 'blank', folderId: selectedFolderId ?? undefined });

‎client/src/lib/http/apiClient.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ interface RequestOptions extends RequestInit {
1111
* 共享的 token 刷新 Promise —— 并发请求同时命中 401 时只触发一次 refreshSession。
1212
* 否则多次并发刷新会因 Supabase 的 refresh token 轮换而互相失效,
1313
* 让本来有效的会话被误判为过期,从而偶发地强制用户重新登录。
14-
*
15-
* @ai-context: 日志/错误前缀统一为 [EntropyDecrease]。VITE_API_BASE_URL
16-
* 由环境文件注入,代码内零硬编码。
14+
*
15+
* @ai-context: 日志/错误前缀统一为 [EntropyDecrease]。VITE_API_BASE_URL
16+
* 由环境文件注入,代码内零硬编码。
1717
*/
1818
let sharedRefresh: ReturnType<typeof supabase.auth.refreshSession> | null = null;
1919
function refreshSessionShared() {
@@ -54,7 +54,10 @@ function createClient(baseUrlOrGetter: string | (() => string)) {
5454

5555
const headers = new Headers(customHeaders as HeadersInit);
5656
if (token) headers.set('Authorization', `Bearer ${token}`);
57-
headers.set('Content-Type', 'application/json');
57+
// multipart(FormData body)不设 Content-Type,由浏览器自动生成 boundary;
58+
// 其余请求统一 JSON
59+
const isFormData = typeof FormData !== 'undefined' && rest.body instanceof FormData;
60+
if (!isFormData) headers.set('Content-Type', 'application/json');
5861

5962
// 生成请求追踪 ID,便于 ai-gateway 端链路追踪
6063
const requestId = crypto.randomUUID();
@@ -127,8 +130,15 @@ function createClient(baseUrlOrGetter: string | (() => string)) {
127130

128131
return {
129132
get: <T = unknown>(url: string) => request<T>(url, { method: 'GET' }),
130-
post: <T = unknown>(url: string, body?: unknown, options?: RequestOptions) =>
131-
request<T>(url, { method: 'POST', body: JSON.stringify(body), ...options }),
133+
post: <T = unknown>(url: string, body?: unknown, options?: RequestOptions) => {
134+
// FormData body 直接透传(multipart 上传),其余 JSON 序列化
135+
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
136+
return request<T>(url, {
137+
method: 'POST',
138+
body: isFormData ? body : JSON.stringify(body),
139+
...options,
140+
});
141+
},
132142
put: <T = unknown>(url: string, body?: unknown) =>
133143
request<T>(url, { method: 'PUT', body: JSON.stringify(body) }),
134144
delete: <T = unknown>(url: string) => request<T>(url, { method: 'DELETE' }),

0 commit comments

Comments
 (0)