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:
Claude Code 2026-04-03 09:17:59 -07:00
parent 3ae440a474
commit 5543eeb93f
3 changed files with 123 additions and 30 deletions

View file

@ -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 {}

View file

@ -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,

View file

@ -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 26 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)}`,
);
}
}
}