fix: message handle (#14178)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Robustly sanitize session titles, messages, attachments, and embedded
data to remove invalid/null characters and prevent corrupt persistence.
* Improve chat title generation to skip or recover from invalid input
and log contextual errors without crashing.
* Add more detailed storage and workspace logs and reduce repetitive
checks to aid troubleshooting and stability.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-12-29 21:47:53 +08:00
committed by GitHub
parent 1bfd29df99
commit e12fe9c12b
4 changed files with 170 additions and 17 deletions
@@ -113,6 +113,99 @@ export type CleanupSessionOptions = Pick<
@Injectable()
export class CopilotSessionModel extends BaseModel {
private sanitizeString<T extends string | null | undefined>(value: T): T {
if (typeof value !== 'string') {
return value;
}
return value.replace(/\u0000/g, '') as T;
}
private sanitizeJsonValue<T>(value: T): T {
if (typeof value === 'string') {
return this.sanitizeString(value) as T;
}
if (Array.isArray(value)) {
return value.map(v => this.sanitizeJsonValue(v)) as T;
}
if (
value &&
typeof value === 'object' &&
Object.getPrototypeOf(value) === Object.prototype
) {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, this.sanitizeJsonValue(v)])
) as T;
}
return value;
}
private sanitizeStreamObject(stream: ChatStreamObject): ChatStreamObject {
switch (stream.type) {
case 'text-delta':
case 'reasoning':
return {
...stream,
textDelta: this.sanitizeString(stream.textDelta),
};
case 'tool-call':
return {
...stream,
toolCallId: this.sanitizeString(stream.toolCallId) ?? '',
toolName: this.sanitizeString(stream.toolName) ?? '',
args: this.sanitizeJsonValue(stream.args),
};
case 'tool-result':
return {
...stream,
toolCallId: this.sanitizeString(stream.toolCallId) ?? '',
toolName: this.sanitizeString(stream.toolName) ?? '',
args: this.sanitizeJsonValue(stream.args),
result: this.sanitizeJsonValue(stream.result),
};
}
}
private sanitizeAttachments(
attachments?: ChatAttachment[] | null
): ChatAttachment[] | undefined {
if (!attachments?.length) {
return undefined;
}
return attachments
.map(attachment =>
typeof attachment === 'string'
? (this.sanitizeString(attachment) ?? '')
: {
attachment:
this.sanitizeString(attachment.attachment) ??
attachment.attachment,
mimeType:
this.sanitizeString(attachment.mimeType) ?? attachment.mimeType,
}
)
.filter(attachment => {
if (typeof attachment === 'string') {
return !!attachment;
}
return !!attachment.attachment && !!attachment.mimeType;
});
}
private sanitizeMessage(message: ChatMessage): ChatMessage {
return {
...message,
content: this.sanitizeString(message.content) ?? '',
attachments: this.sanitizeAttachments(message.attachments),
params: this.sanitizeJsonValue(
omit(message.params, ['docs']) || undefined
),
streamObjects: message.streamObjects?.map(o =>
this.sanitizeStreamObject(o)
),
};
}
getSessionType(session: Pick<ChatSession, 'docId' | 'pinned'>): SessionType {
if (session.pinned) return SessionType.Pinned;
if (!session.docId) return SessionType.Workspace;
@@ -401,6 +494,7 @@ export class CopilotSessionModel extends BaseModel {
internalCall = false
): Promise<string> {
const { userId, sessionId, docId, promptName, pinned, title } = options;
const sanitizedTitle = this.sanitizeString(title);
const session = await this.getExists(
sessionId,
{
@@ -448,7 +542,7 @@ export class CopilotSessionModel extends BaseModel {
await this.db.aiSession.update({
where: { id: sessionId },
data: { docId, promptName, pinned, title },
data: { docId, promptName, pinned, title: sanitizedTitle },
});
return sessionId;
@@ -509,19 +603,23 @@ export class CopilotSessionModel extends BaseModel {
}
if (messages.length) {
const tokenCost = this.calculateTokenSize(messages, state.prompt.model);
const sanitizedMessages = messages.map(m => this.sanitizeMessage(m));
const tokenCost = this.calculateTokenSize(
sanitizedMessages,
state.prompt.model
);
await this.db.aiSessionMessage.createMany({
data: messages.map(m => ({
data: sanitizedMessages.map(m => ({
...m,
attachments: m.attachments || undefined,
params: omit(m.params, ['docs']) || undefined,
params: m.params || undefined,
streamObjects: m.streamObjects || undefined,
sessionId,
})),
});
// only count message generated by user
const userMessages = messages.filter(m => m.role === 'user');
const userMessages = sanitizedMessages.filter(m => m.role === 'user');
await this.db.aiSession.update({
where: { id: sessionId },
data: {