feat(chat): ✨ Add ConversationTitleService for dynamic title management and update ChatService integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
3ae440a474
commit
5543eeb93f
3 changed files with 123 additions and 30 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ConversationTitleService } from './conversation-title.service';
|
||||
import { AiCoreClient } from '../../clients/ai-core.client';
|
||||
import { ModelBossClient } from '../../clients/model-boss.client';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
|
|
@ -9,7 +10,7 @@ import { VoiceSharedModule } from '../voice/voice-shared.module';
|
|||
@Module({
|
||||
imports: [SessionModule, VoiceSharedModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService, AiCoreClient, ModelBossClient],
|
||||
providers: [ChatService, ConversationTitleService, AiCoreClient, ModelBossClient],
|
||||
exports: [AiCoreClient, ModelBossClient],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Response } from 'express';
|
||||
import { AiCoreClient, type ProcessSegment } from '../../clients/ai-core.client';
|
||||
import { TtsPipeline } from '@lilith/tts-pipeline';
|
||||
import type { SynthesizedSegment } from '@lilith/tts-pipeline';
|
||||
import { AiCoreClient } from '../../clients/ai-core.client';
|
||||
import { ModelBossClient } from '../../clients/model-boss.client';
|
||||
import { SessionService } from '../session/session.service';
|
||||
import { ConversationTitleService } from './conversation-title.service';
|
||||
import { VoiceServerRef } from '../voice/voice-server.ref';
|
||||
import { VoiceSessionStore } from '../voice/voice-session.store';
|
||||
|
||||
|
|
@ -18,7 +21,7 @@ import { VoiceSessionStore } from '../voice/voice-session.store';
|
|||
* 4. POST @model-boss /v1/chat/completions (SSE) → token stream
|
||||
* 5. Open Socket.IO @ai /process → send init + tokens + done → receive segments
|
||||
* 6. SSE each segment to browser
|
||||
* 7. For each segment: POST @model-boss /api/v1/tts/synthesize → emit binary audio via VoiceGateway
|
||||
* 7. For each segment: sendTtsRequest via open @speech-synthesis socket → binary audio + events flow to browser
|
||||
* 8. Persist user + assistant messages to DB
|
||||
*/
|
||||
@Injectable()
|
||||
|
|
@ -29,6 +32,7 @@ export class ChatService {
|
|||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly titleService: ConversationTitleService,
|
||||
private readonly aiCore: AiCoreClient,
|
||||
private readonly modelBoss: ModelBossClient,
|
||||
private readonly voiceServerRef: VoiceServerRef,
|
||||
|
|
@ -71,8 +75,8 @@ export class ChatService {
|
|||
|
||||
const collectedSegments: Array<{ text: string; emotion: string }> = [];
|
||||
|
||||
// TTS chain: ensures segments are spoken in order (each waits for the previous to finish playing)
|
||||
let ttsChain: Promise<void> = Promise.resolve();
|
||||
// TTS pipeline: parallel synthesis → ordered sequential delivery
|
||||
const ttsPipeline = this.createTtsPipeline(sessionId, compose.tts.sentence_gap_ms);
|
||||
|
||||
processSocket.onSegment((segment) => {
|
||||
collectedSegments.push({ text: segment.text, emotion: segment.emotion });
|
||||
|
|
@ -89,11 +93,13 @@ export class ChatService {
|
|||
})}\n\n`,
|
||||
);
|
||||
|
||||
ttsChain = ttsChain.then(() =>
|
||||
this.speakSegment(sessionId, segment, compose.tts.emotion).catch((err: Error) => {
|
||||
this.logger.warn(`TTS dispatch failed [${sessionId}] part ${segment.partIndex}: ${err.message}`);
|
||||
}),
|
||||
);
|
||||
// Fire TTS synthesis immediately (parallel) — pipeline handles ordering
|
||||
ttsPipeline.enqueue({
|
||||
partIndex: segment.partIndex,
|
||||
text: segment.text,
|
||||
emotion: segment.emotion,
|
||||
ttsParams: segment.ttsParams,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -135,6 +141,7 @@ export class ChatService {
|
|||
content: fullText,
|
||||
emotion: dominantEmotion,
|
||||
});
|
||||
void this.titleService.maybeGenerateTitle(sessionId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to persist assistant message [${sessionId}]: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
|
@ -144,19 +151,43 @@ export class ChatService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Synthesise one segment via @model-boss TTS and push the audio to the browser
|
||||
* over the active VoiceGateway Socket.IO connection for this session.
|
||||
*
|
||||
* Resolves only after the audio duration has elapsed so the TTS chain
|
||||
* plays segments sequentially rather than overlapping.
|
||||
* Create a TtsPipeline for one chat response.
|
||||
*
|
||||
* Synthesis: fires parallel requests to @model-boss (high priority).
|
||||
* Delivery: emits tts.start → binary PCM → (waits duration) → tts.end
|
||||
* to the browser via the voice gateway, in strict segment order.
|
||||
*/
|
||||
private createTtsPipeline(sessionId: string, sentenceGapMs: number): TtsPipeline {
|
||||
return new TtsPipeline(
|
||||
// Synthesize: parallel — all requests fire immediately
|
||||
async (segment) => {
|
||||
const result = await this.modelBoss.synthesizeTts({
|
||||
text: segment.text,
|
||||
exaggeration: segment.ttsParams.exaggeration,
|
||||
cfgWeight: segment.ttsParams.cfgWeight,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
// Deliver: called in order by the pipeline after pacing
|
||||
(segment: SynthesizedSegment) => {
|
||||
this.deliverAudioToClient(sessionId, segment);
|
||||
},
|
||||
{
|
||||
gapMs: sentenceGapMs,
|
||||
onError: (segment, error) => {
|
||||
this.logger.warn(
|
||||
`TTS synthesis failed [${sessionId}] part ${segment.partIndex}: ${error.message}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push synthesized audio to the browser via the voice gateway Socket.IO.
|
||||
* Silently no-ops if no voice WebSocket is open for this session.
|
||||
*/
|
||||
private async speakSegment(
|
||||
sessionId: string,
|
||||
segment: ProcessSegment,
|
||||
emotionConfig: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
private deliverAudioToClient(sessionId: string, segment: SynthesizedSegment): void {
|
||||
const voiceSession = this.voiceSessionStore.get(sessionId);
|
||||
if (!voiceSession) return;
|
||||
|
||||
|
|
@ -166,14 +197,8 @@ export class ChatService {
|
|||
const browserSocket = server.sockets.sockets.get(voiceSession.browserSocketId);
|
||||
if (!browserSocket) return;
|
||||
|
||||
const { pcm, durationMs } = await this.modelBoss.synthesizeTts({
|
||||
text: segment.text,
|
||||
exaggeration: segment.ttsParams.exaggeration,
|
||||
cfgWeight: segment.ttsParams.cfgWeight,
|
||||
});
|
||||
|
||||
const utteranceId = randomUUID().replace(/-/g, '').slice(0, 16);
|
||||
const frame = buildDownstreamFrame(this._seq++, utteranceId, pcm);
|
||||
const frame = buildDownstreamFrame(this._seq++, utteranceId, segment.pcm);
|
||||
|
||||
browserSocket.emit('event', {
|
||||
type: 'tts.start',
|
||||
|
|
@ -186,9 +211,6 @@ export class ChatService {
|
|||
|
||||
browserSocket.emit('binary', frame);
|
||||
|
||||
// Hold the chain until the audio has had time to play before starting next segment
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, durationMs));
|
||||
|
||||
browserSocket.emit('event', {
|
||||
type: 'tts.end',
|
||||
session_id: sessionId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModelBossClient } from '../../clients/model-boss.client';
|
||||
import { SessionService } from '../session/session.service';
|
||||
|
||||
const TITLE_EVERY_N_MESSAGES = 5;
|
||||
const TITLE_CONTEXT_MESSAGES = 10;
|
||||
|
||||
@Injectable()
|
||||
export class ConversationTitleService {
|
||||
private readonly logger = new Logger(ConversationTitleService.name);
|
||||
|
||||
constructor(
|
||||
private readonly modelBoss: ModelBossClient,
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate a conversation title if:
|
||||
* - total message count is a multiple of TITLE_EVERY_N_MESSAGES, AND
|
||||
* - the session title has not been manually set by the user.
|
||||
*
|
||||
* Fires best-effort; never throws.
|
||||
*/
|
||||
async maybeGenerateTitle(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const [session, count] = await Promise.all([
|
||||
this.sessionService.getSession(sessionId),
|
||||
this.sessionService.getMessageCount(sessionId),
|
||||
]);
|
||||
|
||||
if (count === 0 || count % TITLE_EVERY_N_MESSAGES !== 0) return;
|
||||
if (session.titleIsManual) return;
|
||||
|
||||
const history = await this.sessionService.getHistory(sessionId);
|
||||
const recent = history.slice(-TITLE_CONTEXT_MESSAGES);
|
||||
|
||||
const prompt = recent
|
||||
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
|
||||
.join('\n');
|
||||
|
||||
const title = await this.modelBoss.complete(
|
||||
[
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'/no_think\nYou are a conversation labeller. ' +
|
||||
'Given a chat transcript, reply with ONLY a short title of 2–6 words. ' +
|
||||
'No quotes, no punctuation at the end.',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
{ max_tokens: 20, temperature: 0.3 },
|
||||
);
|
||||
|
||||
const cleaned = title
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\.$/, '')
|
||||
.trim();
|
||||
|
||||
if (!cleaned) return;
|
||||
|
||||
await this.sessionService.updateTitle(sessionId, cleaned, false);
|
||||
this.logger.debug(`Auto-titled session ${sessionId}: "${cleaned}"`);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Title generation failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue