feat(memory,conversation): 记忆富化/证据包、时间线幂等字段与对话分段全链路

数据库
- 新增迁移 0003:timeline_events.memory_source_id 外键 → memory_sources,便于按 ingest 源做时间线幂等

后端 - 记忆
- 新增 ingest 后 LLM 富化(摘要/事实/时间线),可配置开关与最大字符数
- 新增证据包组装:合并 chunk、摘要、事实、时间线、故事等检索结果;支持空 query 时是否仍带 rolling 等开关
- repo/retriever/service/router/schemas/summarizer/timeline/extractor 等扩展;文档 memory-retrieval.md 更新

后端 - 对话 WS
- 增加 PING/PONG;分段 ASR 日志与空音频处理;转写失败与「无助手回复」错误提示更明确
- 助手多段回复持久化使用统一分隔符,与分段逻辑一致

后端 - Agent
- reply_limits:按 [SPLIT] 与段落拆段,并保证非空 fallback,供 WS 与 TTS 多段下发

后端 - 回忆录任务
- transcript ingest 记录 source_id;任务成功结?
This commit is contained in:
Kevin
2026-03-27 16:01:28 +08:00
parent 1374f6e8f5
commit e4bf0710c7
70 changed files with 3404 additions and 557 deletions

View File

@@ -47,7 +47,13 @@ export function usePlayer(): UsePlayerResult {
typeof currentSource === 'string' &&
(currentSource.startsWith('https://') ||
currentSource.startsWith('http://'));
return { downloadFirst: remote };
return {
downloadFirst: remote,
// Expo's native player deactivates AVAudioSession on pause by default.
// We manage session ownership centrally via audioFocus, so keep it active
// until audioFocus.release() explicitly tears it down.
keepAudioSessionActive: true,
};
}, [currentSource]);
const player = useAudioPlayer(currentSource, playerOptions);
@@ -76,7 +82,7 @@ export function usePlayer(): UsePlayerResult {
setCurrentSource(null);
setStatus('idle');
setQueueLength(0);
await audioFocus.release();
await audioFocus.releaseIfOwnedBy('player');
return;
}
@@ -170,7 +176,7 @@ export function usePlayer(): UsePlayerResult {
setCurrentPlaybackItem(null);
setCurrentSource(null);
setStatus('idle');
await audioFocus.release();
await audioFocus.releaseIfOwnedBy('player');
await playNext();
},
[player, playNext],
@@ -189,7 +195,7 @@ export function usePlayer(): UsePlayerResult {
setCurrentPlaybackItem(null);
setCurrentSource(null);
setStatus('idle');
await audioFocus.release();
await audioFocus.releaseIfOwnedBy('player');
}, [player]);
return {

View File

@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { VoiceRecorder } from '../recorder';
import type { RecorderStatus } from '../types';
import type { RecorderStartResult, RecorderStatus } from '../types';
interface UseRecorderResult {
status: RecorderStatus;
durationMs: number;
start: () => Promise<boolean>;
start: () => Promise<RecorderStartResult>;
stop: () => Promise<{ uri: string; durationMs: number } | null>;
cancel: () => Promise<void>;
}
@@ -44,16 +44,21 @@ export function useRecorder(
const start = useCallback(async () => {
const recorder = recorderRef.current;
if (!recorder) return false;
if (!recorder) {
return {
ok: false,
reason: 'recorder_unavailable',
} as const;
}
const ok = await recorder.start();
if (ok) {
const result = await recorder.start();
if (result.ok) {
setDurationMs(0);
durationTimer.current = setInterval(() => {
setDurationMs(recorder.getDurationMs());
}, 200);
}
return ok;
return result;
}, []);
const stop = useCallback(async () => {

View File

@@ -2,12 +2,13 @@ import { Platform } from 'react-native';
import {
AudioModule,
AudioQuality,
IOSOutputFormat,
type RecordingOptions,
RecordingPresets,
} from 'expo-audio';
import { audioFocus } from '@/core/audio/audio-focus';
import type { RecorderStatus } from './types';
import type { RecorderStartResult, RecorderStatus } from './types';
// Native module exposes AudioRecorder as constructor; ESLint import/namespace doesn't resolve it
// eslint-disable-next-line import/namespace -- AudioModule.AudioRecorder exists at runtime
@@ -16,6 +17,30 @@ const AudioRecorderCtor = AudioModule.AudioRecorder;
type StatusListener = (status: RecorderStatus) => void;
type RecordingCompleteListener = (uri: string, durationMs: number) => void;
/**
* Tencent SentenceRecognition is currently called with `EngSerViceType=16k_zh`
* and `VoiceFormat=m4a`, so record speech in that shape directly instead of
* relying on Expo's default 44.1 kHz stereo preset.
*/
export const VOICE_RECORDING_OPTIONS: RecordingOptions = {
extension: '.m4a',
sampleRate: 16_000,
numberOfChannels: 1,
bitRate: 32_000,
android: {
outputFormat: 'mpeg4',
audioEncoder: 'aac',
},
ios: {
outputFormat: IOSOutputFormat.MPEG4AAC,
audioQuality: AudioQuality.HIGH,
},
web: {
mimeType: 'audio/webm',
bitsPerSecond: 32_000,
},
};
/** Platform-specific recording options (expo-audio internal createRecordingOptions logic). */
function createRecordingOptions(
options: RecordingOptions,
@@ -32,6 +57,15 @@ function createRecordingOptions(
return { ...common, ...options.web };
}
export function buildVoiceRecordingOptions(): Partial<RecordingOptions> {
return createRecordingOptions(VOICE_RECORDING_OPTIONS);
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
/**
* Class-level wrapper over expo-audio recording.
* No React dependency — hooks/ layer adapts this to React.
@@ -55,29 +89,35 @@ export class VoiceRecorder {
return result.granted;
}
async start(): Promise<boolean> {
if (this.status !== 'idle') return false;
async start(): Promise<RecorderStartResult> {
if (this.status !== 'idle') {
return { ok: false, reason: 'prepare_failed' };
}
const hasPermission = await this.requestPermission();
if (!hasPermission) return false;
if (!hasPermission) {
return { ok: false, reason: 'permission_denied' };
}
const acquired = await audioFocus.acquireForRecording();
if (!acquired) return false;
if (!acquired) {
return { ok: false, reason: 'audio_focus_unavailable' };
}
this.setStatus('preparing');
try {
this.recorder = new AudioRecorderCtor(
createRecordingOptions(RecordingPresets.HIGH_QUALITY),
);
this.recorder = new AudioRecorderCtor(buildVoiceRecordingOptions());
await this.recorder.prepareToRecordAsync();
this.recorder.record();
this.startTime = Date.now();
this.setStatus('recording');
return true;
} catch {
return { ok: true };
} catch (error) {
const errorMessage = getErrorMessage(error);
console.warn('VoiceRecorder.start failed during prepare', errorMessage);
await this.cleanup();
return false;
return { ok: false, reason: 'prepare_failed', errorMessage };
}
}
@@ -151,6 +191,6 @@ export class VoiceRecorder {
this.recorder = null;
this.startTime = 0;
this.setStatus('idle');
await audioFocus.release();
await audioFocus.releaseIfOwnedBy('recorder');
}
}

View File

@@ -2,6 +2,18 @@
export type RecorderStatus = 'idle' | 'preparing' | 'recording' | 'stopping';
export type RecorderStartFailureReason =
| 'permission_denied'
| 'audio_focus_unavailable'
| 'prepare_failed'
| 'recorder_unavailable';
export interface RecorderStartResult {
ok: boolean;
reason?: RecorderStartFailureReason;
errorMessage?: string;
}
// ─── Segmenter ───
export type SegmentStrategy = 'fixed-duration';